Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/radios/dm32uv/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/radios/dm32uv/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
42 changes: 30 additions & 12 deletions src/radios/dm32uv/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand All @@ -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
);
});

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 14 additions & 7 deletions src/radios/dm32uv/structures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/structures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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);
});
});
Loading