diff --git a/src/App.tsx b/src/App.tsx index 75049b5..01e4883 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,11 @@ import { sampleChannels, sampleContacts, sampleZones } from './utils/sampleData' import { setLogStore, logger, LogLevel } from './utils/protocolLogger'; import { useLogStore } from './store/logStore'; +// TEMP DEBUG HOOK - exposes zonesStore to the console for local testing. Remove before commit. +if (import.meta.env.DEV) { + (window as unknown as { __zonesStore: typeof useZonesStore }).__zonesStore = useZonesStore; +} + function App() { const [activeTab, setActiveTab] = useState('channels'); const [showStartupModal, setShowStartupModal] = useState(true); diff --git a/src/radios/dm32uv/constants.ts b/src/radios/dm32uv/constants.ts index a7f0c8a..80ee24b 100644 --- a/src/radios/dm32uv/constants.ts +++ b/src/radios/dm32uv/constants.ts @@ -7,7 +7,8 @@ export const METADATA = { CHANNEL_FIRST: 0x12, // First channel block CHANNEL_LAST: 0x41, // Last channel block (max) - ZONE: 0x5c, // Zone block + ZONE_FIRST: 0x5c, // First zone block + ZONE_LAST: 0x64, // Last zone block (9 blocks x 28 zones/block = 252, covers LIMITS.ZONES_MAX 250) — unverified, pending hardware confirmation SCAN_LIST: 0x11, // Scan list block DIGITAL_EMERGENCY: 0x10, // Digital Emergency Systems (same block as encryption keys, offset 0x000) VFO_SETTINGS: 0x04, // Radio Settings / Radio Names / Embedded Information diff --git a/src/radios/dm32uv/memory.ts b/src/radios/dm32uv/memory.ts index 805fc28..efcaf2e 100644 --- a/src/radios/dm32uv/memory.ts +++ b/src/radios/dm32uv/memory.ts @@ -46,8 +46,8 @@ export async function discoverMemoryBlocks( type = 'empty'; } else if (metadata >= 0x12 && metadata <= 0x41) { type = 'channel'; // Channel blocks (0x12 = first, 0x41 = last) - } else if (metadata === 0x5c) { - type = 'zone'; // Zones identified as metadata 0x5c (92) from debug export analysis + } else if (metadata >= 0x5c && metadata <= 0x64) { + type = 'zone'; // Zone blocks (0x5c = first, 0x64 = last, 9 blocks) — extended from the single 0x5c value identified in debug export analysis to cover LIMITS.ZONES_MAX (250) } else if (metadata === 0x11) { type = 'scan'; // Scan lists identified as metadata 0x11 (17) from debug export analysis } else if (metadata === 0x03) { diff --git a/src/radios/dm32uv/protocol.ts b/src/radios/dm32uv/protocol.ts index f2ff6b6..8da2a38 100644 --- a/src/radios/dm32uv/protocol.ts +++ b/src/radios/dm32uv/protocol.ts @@ -1569,9 +1569,11 @@ export class DM32UVProtocol extends BaseDigitalProtocol implements DM32Protocol this.onProgress?.(0, 'Parsing zones from cached blocks...'); - // Zone metadata identified from debug export: 0x5c - const zoneBlocks = this.discoveredBlocks.filter(b => b.metadata === METADATA.ZONE); - log.info(`Found ${zoneBlocks.length} zone blocks (metadata 0x${METADATA.ZONE.toString(16)})`, 'Protocol'); + // Zone blocks span metadata 0x5c-0x64 (9 blocks, covers LIMITS.ZONES_MAX) + const zoneBlocks = this.discoveredBlocks + .filter(b => b.type === 'zone') + .sort((a, b) => a.metadata - b.metadata); + log.info(`Found ${zoneBlocks.length} zone blocks (metadata 0x${METADATA.ZONE_FIRST.toString(16)}-0x${METADATA.ZONE_LAST.toString(16)})`, 'Protocol'); if (checkEmptyBlocks(zoneBlocks, 'zone', this.onProgress)) { return []; @@ -1583,12 +1585,19 @@ export class DM32UVProtocol extends BaseDigitalProtocol implements DM32Protocol this.onProgress?.(50, 'Parsing zone data...'); const zones = parseZones(allZoneData, (zoneNum, rawData, name) => { // Store raw zone data for debug export + // Offset math mirrors parseZones(): first block has a 16-byte header, later blocks don't + const zoneIdx = zoneNum - 1; + const blockIdx = Math.floor(zoneIdx / LIMITS.ZONES_PER_BLOCK); + const indexInBlock = zoneIdx % LIMITS.ZONES_PER_BLOCK; + const zoneOffset = blockIdx === 0 + ? OFFSET.ZONE_START + indexInBlock * BLOCK_SIZE.ZONE + : blockIdx * BLOCK_SIZE.STANDARD + indexInBlock * BLOCK_SIZE.ZONE; storeRawData( this.rawZoneData, name, rawData, { zoneNum }, - OFFSET.ZONE_START + (zoneNum - 1) * BLOCK_SIZE.ZONE + zoneOffset ); }); @@ -1629,8 +1638,12 @@ export class DM32UVProtocol extends BaseDigitalProtocol implements DM32Protocol this.discoveredBlocks = blocks; } - // Get zone blocks (metadata 0x5c) - const zoneBlocks = this.discoveredBlocks.filter(b => b.metadata === METADATA.ZONE); + // NOTE: unlike writeAllData() (the multi-block-aware path actually used by the app), + // this legacy single-block method only targets the first zone block and intentionally + // does not recognize the full 0x5c-0x64 zone range — its flat write-offset math below + // isn't block-boundary aware, so writing to multiple blocks here would silently + // misplace zone data. Fix that before broadening this to multiple blocks. + const zoneBlocks = this.discoveredBlocks.filter(b => b.metadata === METADATA.ZONE_FIRST); if (zoneBlocks.length === 0) { throw new Error('No zone blocks found'); @@ -3402,7 +3415,10 @@ export class DM32UVProtocol extends BaseDigitalProtocol implements DM32Protocol } // Generate zone blocks - ALWAYS write zones when writing channels - const zoneBlocks = this.discoveredBlocks.filter(b => b.metadata === METADATA.ZONE); + // Zone blocks span metadata 0x5c-0x64 (9 blocks, covers LIMITS.ZONES_MAX) + const zoneBlocks = this.discoveredBlocks + .filter(b => b.type === 'zone') + .sort((a, b) => a.metadata - b.metadata); if (zoneBlocks.length === 0) { throw new Error('No zone blocks found'); } @@ -3511,11 +3527,13 @@ export class DM32UVProtocol extends BaseDigitalProtocol implements DM32Protocol const destOffset = isFirstBlock ? OFFSET.ZONE_START : 0; blockData.set(sourceData, destOffset); - // Only set zone count at byte 0 for the first block + // Byte 0 of the first block holds the GLOBAL total zone count (not just + // this block's share) - confirmed against hardware: a radio with 29 real + // zones had byte 0 = 0x1d (29), not clamped to 28. if (isFirstBlock) { - const zoneCount = Math.min(Math.max(zonesInBlock, 1), 28); // Clamp to 1-28 + const zoneCount = Math.min(Math.max(zonesToWrite.length, 1), 255); blockData[0] = zoneCount; - log.debug(`Set zone count in byte 0: ${zoneCount} zones for first block`, 'Protocol'); + log.debug(`Set zone count in byte 0: ${zoneCount} (total zones across all blocks)`, 'Protocol'); // Preserve the original bytes 1-15 if available (to match original structure) if (originalBlockData) { @@ -3750,9 +3768,9 @@ export class DM32UVProtocol extends BaseDigitalProtocol implements DM32Protocol finalBlocksToWrite.push(block); } - // 2. Zone blocks (metadata 0x5c) + // 2. Zone blocks (metadata 0x5c-0x64) const zoneBlocksToWrite = blocksToWrite - .filter(b => b.metadata === METADATA.ZONE) + .filter(b => b.metadata >= METADATA.ZONE_FIRST && b.metadata <= METADATA.ZONE_LAST) .sort((a, b) => a.address - b.address); for (const block of zoneBlocksToWrite) { diff --git a/src/radios/dm32uv/structures.ts b/src/radios/dm32uv/structures.ts index 4352178..0801ccf 100644 --- a/src/radios/dm32uv/structures.ts +++ b/src/radios/dm32uv/structures.ts @@ -784,13 +784,20 @@ export function parseZones( ): Zone[] { const zones: Zone[] = []; - // Zones are 145 bytes each, starting at offset 16 - // Zone 1: offset 16 - // Zone 2: offset 161 (16 + 145) - // Zone 3: offset 306 (16 + 145*2) - // Maximum zones: (4096 - 16) / 145 ≈ 28 zones per 4KB block - for (let zoneNum = 1; zoneNum <= 30; zoneNum++) { - const offset = 16 + (zoneNum - 1) * 145; + // Zones are 145 bytes each. The first zone block reserves a 16-byte header + // (zone count), so zones start at offset 16 there; every subsequent 4KB + // block has no header and zones start at offset 0. Both cases hold exactly + // LIMITS.ZONES_PER_BLOCK (28) zones, so blocks can be indexed uniformly. + // Zone 1: offset 16 (block 0) + // Zone 29: offset 4096 (block 1, byte 0 - no header) + // Zone 57: offset 8192 (block 2, byte 0 - no header) + for (let zoneNum = 1; zoneNum <= LIMITS.ZONES_MAX; zoneNum++) { + const zoneIdx = zoneNum - 1; + const blockIdx = Math.floor(zoneIdx / LIMITS.ZONES_PER_BLOCK); + const indexInBlock = zoneIdx % LIMITS.ZONES_PER_BLOCK; + const offset = blockIdx === 0 + ? OFFSET.ZONE_START + indexInBlock * BLOCK_SIZE.ZONE + : blockIdx * BLOCK_SIZE.STANDARD + indexInBlock * BLOCK_SIZE.ZONE; if (offset + 145 > data.length) { log.debug(`Zone ${zoneNum} would be at offset ${offset}, but data length is only ${data.length}`, 'Structures'); break; diff --git a/tests/unit/structures.test.ts b/tests/unit/structures.test.ts index 6650ae3..19ca440 100644 --- a/tests/unit/structures.test.ts +++ b/tests/unit/structures.test.ts @@ -4,8 +4,32 @@ import { encodeBCDFrequency, decodeCTCSSDCS, encodeCTCSSDCS, + parseZones, + encodeZone, } from '../../src/radios/dm32uv/structures'; import { DCS_CODES } from '../../src/utils/ctcssConstants'; +import { LIMITS, BLOCK_SIZE, OFFSET } from '../../src/radios/dm32uv/constants'; +import type { Zone } from '../../src/models'; + +// Builds a concatenated multi-block zone buffer the way protocol.ts's +// concatenateCachedBlocks() does: block 0 has a 16-byte header before zones +// start, every later 4KB block has zones starting at byte 0. +function buildZoneBuffer(zones: Zone[]): Uint8Array { + const blockCount = Math.max(1, Math.ceil(zones.length / LIMITS.ZONES_PER_BLOCK)); + const data = new Uint8Array(blockCount * BLOCK_SIZE.STANDARD); + data.fill(0xFF); + + zones.forEach((zone, idx) => { + const blockIdx = Math.floor(idx / LIMITS.ZONES_PER_BLOCK); + const indexInBlock = idx % LIMITS.ZONES_PER_BLOCK; + const offset = blockIdx === 0 + ? OFFSET.ZONE_START + indexInBlock * BLOCK_SIZE.ZONE + : blockIdx * BLOCK_SIZE.STANDARD + indexInBlock * BLOCK_SIZE.ZONE; + data.set(encodeZone(zone, idx + 1), offset); + }); + + return data; +} // ─── BCD frequency ──────────────────────────────────────────────────────────── @@ -202,3 +226,53 @@ describe('CTCSS/DCS round-trip', () => { }); } }); + +// ─── Zone parsing across block boundaries ───────────────────────────────────── +// Regression coverage for a bug where zones beyond the first 4KB block (28 zones, +// since the first block reserves a 16-byte header) were silently misread, and a +// hardcoded 30-zone cap dropped everything past it. See LIMITS.ZONES_MAX (250). + +describe('parseZones across multiple blocks', () => { + function makeZones(count: number): Zone[] { + return Array.from({ length: count }, (_, i) => ({ + id: `z${i + 1}`, + name: `Zone${i + 1}`, + channels: [i + 1, i + 2], + })); + } + + it('parses a single block of 28 zones (fills the first block exactly)', () => { + const zones = makeZones(LIMITS.ZONES_PER_BLOCK); + const data = buildZoneBuffer(zones); + const parsed = parseZones(data); + expect(parsed).toHaveLength(28); + expect(parsed[0].name).toBe('Zone1'); + expect(parsed[27].name).toBe('Zone28'); + }); + + it('correctly parses zone 29, the first zone in the second block', () => { + const zones = makeZones(30); + const data = buildZoneBuffer(zones); + const parsed = parseZones(data); + expect(parsed).toHaveLength(30); + expect(parsed[28].name).toBe('Zone29'); + expect(parsed[28].channels).toEqual([29, 30]); + expect(parsed[29].name).toBe('Zone30'); + }); + + it('parses more than 30 zones (past the old hardcoded cap) across three blocks', () => { + const zones = makeZones(60); + const data = buildZoneBuffer(zones); + const parsed = parseZones(data); + expect(parsed).toHaveLength(60); + expect(parsed[56].name).toBe('Zone57'); // first zone of the third block + expect(parsed[59].name).toBe('Zone60'); + }); + + it('stops at an empty zone within a later block instead of reading garbage', () => { + const zones = makeZones(29); // one zone into the second block + const data = buildZoneBuffer(zones); + const parsed = parseZones(data); + expect(parsed).toHaveLength(29); + }); +});