From 8bb8503d09df6701db6fd18f80138e1896925954 Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Tue, 7 Jul 2026 17:03:37 -0700 Subject: [PATCH 1/6] Fix DCS decoding and encoding issue that would break encoding of new DCS values --- src/radios/dm32uv/structures.ts | 14 +++++--- tests/unit/structures.test.ts | 57 +++++++++++++++++---------------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/radios/dm32uv/structures.ts b/src/radios/dm32uv/structures.ts index 3f8f913..4352178 100644 --- a/src/radios/dm32uv/structures.ts +++ b/src/radios/dm32uv/structures.ts @@ -56,9 +56,11 @@ export function decodeCTCSSDCS(data: Uint8Array): CTCSSDCSResult { return { type: 'None' }; } if (high >= 0x80) { + // DCS code digits are stored as BCD nibbles (DM32-Protocol-Spec/06-ENCODING.md): + // low byte = tens/ones digits, high byte low nibble = hundreds digit. + // High byte base: 0x80-0xBF = normal, 0xC0-0xFF = inverted. D754I → [0x54, 0xC7] const isInverted = high >= 0xC0; - const highNibble = high & 0x0F; - const code = (highNibble << 8) | low; + const code = (high & 0x0F) * 100 + ((low >> 4) & 0x0F) * 10 + (low & 0x0F); const polarity = isInverted ? 'P' : 'N'; return { type: 'DCS', value: code, polarity }; } @@ -78,9 +80,13 @@ export function encodeCTCSSDCS(ctcssDcs: CTCSSDCSResult): Uint8Array { return new Uint8Array([0x00, 0x00]); } if (ctcssDcs.type === 'DCS') { + // DCS code digits are stored as BCD nibbles — see decodeCTCSSDCS. D754I → [0x54, 0xC7] const code = ctcssDcs.value; - const polarityBit = ctcssDcs.polarity === 'P' ? 0x01 : 0x00; - return new Uint8Array([code, 0x80 | polarityBit]); + const hundreds = Math.floor(code / 100) % 10; + const tens = Math.floor((code % 100) / 10); + const ones = code % 10; + const base = ctcssDcs.polarity === 'P' ? 0xC0 : 0x80; + return new Uint8Array([(tens << 4) | ones, base | hundreds]); } const frequency = ctcssDcs.value; const integerPart = Math.floor(frequency); diff --git a/tests/unit/structures.test.ts b/tests/unit/structures.test.ts index 407a8ac..6650ae3 100644 --- a/tests/unit/structures.test.ts +++ b/tests/unit/structures.test.ts @@ -5,6 +5,7 @@ import { decodeCTCSSDCS, encodeCTCSSDCS, } from '../../src/radios/dm32uv/structures'; +import { DCS_CODES } from '../../src/utils/ctcssConstants'; // ─── BCD frequency ──────────────────────────────────────────────────────────── @@ -99,29 +100,29 @@ describe('decodeCTCSSDCS', () => { expect(r.value).toBeCloseTo(203.5, 1); }); - it('decodes DCS normal polarity (high=0x80, code in low byte)', () => { - // code 23 decimal, high=0x80 → DCS, not inverted - expect(decodeCTCSSDCS(new Uint8Array([0x17, 0x80]))).toEqual({ + it('decodes DCS normal polarity (BCD digits, high byte 0x80-0xBF)', () => { + // D023N → [0x23, 0x80] per DM32-Protocol-Spec/06-ENCODING.md + expect(decodeCTCSSDCS(new Uint8Array([0x23, 0x80]))).toEqual({ type: 'DCS', value: 23, polarity: 'N', }); }); - it('decodes DCS inverted polarity (high >= 0xC0)', () => { - // code 23, high=0xC0 → DCS, inverted - expect(decodeCTCSSDCS(new Uint8Array([0x17, 0xC0]))).toEqual({ + it('decodes DCS inverted polarity (high byte >= 0xC0)', () => { + // D023I → [0x23, 0xC0] + expect(decodeCTCSSDCS(new Uint8Array([0x23, 0xC0]))).toEqual({ type: 'DCS', value: 23, polarity: 'P', }); }); - it('decodes DCS codes > 255 using high nibble of high byte', () => { - // high=0x81: DCS (>=0x80), not inverted (<0xC0), highNibble=0x01 → code=(1<<8)|0x2C=300 - expect(decodeCTCSSDCS(new Uint8Array([0x2C, 0x81]))).toEqual({ + it('decodes DCS codes >= 100 (hundreds digit in high byte low nibble)', () => { + // D754N → [0x54, 0x87] + expect(decodeCTCSSDCS(new Uint8Array([0x54, 0x87]))).toEqual({ type: 'DCS', - value: 300, + value: 754, polarity: 'N', }); }); @@ -154,9 +155,17 @@ describe('encodeCTCSSDCS', () => { expect(encodeCTCSSDCS({ type: 'CTCSS', value: 203.5 })).toEqual(new Uint8Array([0x35, 0x20])); }); - it('encodes DCS normal polarity as [code, 0x80]', () => { + it('encodes DCS normal polarity as BCD digits with 0x80 base', () => { + // D023N → [0x23, 0x80] per DM32-Protocol-Spec/06-ENCODING.md expect(encodeCTCSSDCS({ type: 'DCS', value: 23, polarity: 'N' })).toEqual( - new Uint8Array([0x17, 0x80]) + new Uint8Array([0x23, 0x80]) + ); + }); + + it('encodes DCS inverted polarity with 0xC0 base and hundreds digit', () => { + // D754I → [0x54, 0xC7] + expect(encodeCTCSSDCS({ type: 'DCS', value: 754, polarity: 'P' })).toEqual( + new Uint8Array([0x54, 0xC7]) ); }); @@ -184,20 +193,12 @@ describe('CTCSS/DCS round-trip', () => { }); } - it('DCS normal polarity round-trips for codes ≤ 255', () => { - const input = { type: 'DCS' as const, value: 23, polarity: 'N' as const }; - expect(decodeCTCSSDCS(encodeCTCSSDCS(input))).toEqual(input); - }); - - // Known encoder bug: encodeCTCSSDCS uses polarityBit=0x01 for inverted ('P'), producing - // high byte 0x81. But decodeCTCSSDCS expects high >= 0xC0 for inverted and reads - // bit 0 of the high byte as part of the code's highNibble. The round-trip is broken: - // encode({type:'DCS', value:23, polarity:'P'}) → [0x17, 0x81] - // decode([0x17, 0x81]) → {type:'DCS', value:279, polarity:'N'} ← wrong value and polarity - // This test locks in the current broken behaviour so any future fix is a deliberate change. - it('DCS inverted polarity does NOT round-trip (known encoder bug)', () => { - const input = { type: 'DCS' as const, value: 23, polarity: 'P' as const }; - const roundTripped = decodeCTCSSDCS(encodeCTCSSDCS(input)); - expect(roundTripped).not.toEqual(input); - }); + for (const polarity of ['N', 'P'] as const) { + it(`every standard DCS code round-trips with polarity ${polarity}`, () => { + for (const code of DCS_CODES) { + const input = { type: 'DCS' as const, value: code, polarity }; + expect(decodeCTCSSDCS(encodeCTCSSDCS(input))).toEqual(input); + } + }); + } }); From 3b62a269b1725d4585ce844cee2a5017f8186c3b Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Wed, 8 Jul 2026 00:22:52 -0700 Subject: [PATCH 2/6] Fix marine channels --- src/data/fixedChannels.json | 298 ++++++++++++++++++------------------ 1 file changed, 149 insertions(+), 149 deletions(-) diff --git a/src/data/fixedChannels.json b/src/data/fixedChannels.json index 7ac37a8..f55a347 100644 --- a/src/data/fixedChannels.json +++ b/src/data/fixedChannels.json @@ -20,13 +20,13 @@ { "number": 12, "rx": 467.6625, "tx": 467.6625, "name": "FRS 12" }, { "number": 13, "rx": 467.6875, "tx": 467.6875, "name": "FRS 13" }, { "number": 14, "rx": 467.7125, "tx": 467.7125, "name": "FRS 14" }, - { "number": 15, "rx": 467.550, "tx": 467.550, "name": "FRS 15" }, + { "number": 15, "rx": 467.55, "tx": 467.55, "name": "FRS 15" }, { "number": 16, "rx": 467.575, "tx": 467.575, "name": "FRS 16" }, - { "number": 17, "rx": 467.600, "tx": 467.600, "name": "FRS 17" }, + { "number": 17, "rx": 467.6, "tx": 467.6, "name": "FRS 17" }, { "number": 18, "rx": 467.625, "tx": 467.625, "name": "FRS 18" }, - { "number": 19, "rx": 467.650, "tx": 467.650, "name": "FRS 19" }, + { "number": 19, "rx": 467.65, "tx": 467.65, "name": "FRS 19" }, { "number": 20, "rx": 467.675, "tx": 467.675, "name": "FRS 20" }, - { "number": 21, "rx": 467.700, "tx": 467.700, "name": "FRS 21" }, + { "number": 21, "rx": 467.7, "tx": 467.7, "name": "FRS 21" }, { "number": 22, "rx": 467.725, "tx": 467.725, "name": "FRS 22" } ] }, @@ -51,22 +51,22 @@ { "number": 12, "rx": 467.6625, "tx": 462.6625, "name": "GMRS 12" }, { "number": 13, "rx": 467.6875, "tx": 462.6875, "name": "GMRS 13" }, { "number": 14, "rx": 467.7125, "tx": 462.7125, "name": "GMRS 14" }, - { "number": 15, "rx": 462.5500, "tx": 462.5500, "name": "GMRS 15" }, - { "number": 16, "rx": 462.5750, "tx": 462.5750, "name": "GMRS 16" }, - { "number": 17, "rx": 462.6000, "tx": 462.6000, "name": "GMRS 17" }, - { "number": 18, "rx": 462.6250, "tx": 462.6250, "name": "GMRS 18" }, - { "number": 19, "rx": 462.6500, "tx": 462.6500, "name": "GMRS 19" }, - { "number": 20, "rx": 462.6750, "tx": 462.6750, "name": "GMRS 20" }, - { "number": 21, "rx": 462.7000, "tx": 462.7000, "name": "GMRS 21" }, - { "number": 22, "rx": 462.7250, "tx": 462.7250, "name": "GMRS 22" }, - { "number": 23, "rx": 462.5500, "tx": 467.5500, "name": "GMRS 23" }, - { "number": 24, "rx": 462.5750, "tx": 467.5750, "name": "GMRS 24" }, - { "number": 25, "rx": 462.6000, "tx": 467.6000, "name": "GMRS 25" }, - { "number": 26, "rx": 462.6250, "tx": 467.6250, "name": "GMRS 26" }, - { "number": 27, "rx": 462.6500, "tx": 467.6500, "name": "GMRS 27" }, - { "number": 28, "rx": 462.6750, "tx": 467.6750, "name": "GMRS 28" }, - { "number": 29, "rx": 462.7000, "tx": 467.7000, "name": "GMRS 29" }, - { "number": 30, "rx": 462.7250, "tx": 467.7250, "name": "GMRS 30" } + { "number": 15, "rx": 462.55, "tx": 462.55, "name": "GMRS 15" }, + { "number": 16, "rx": 462.575, "tx": 462.575, "name": "GMRS 16" }, + { "number": 17, "rx": 462.6, "tx": 462.6, "name": "GMRS 17" }, + { "number": 18, "rx": 462.625, "tx": 462.625, "name": "GMRS 18" }, + { "number": 19, "rx": 462.65, "tx": 462.65, "name": "GMRS 19" }, + { "number": 20, "rx": 462.675, "tx": 462.675, "name": "GMRS 20" }, + { "number": 21, "rx": 462.7, "tx": 462.7, "name": "GMRS 21" }, + { "number": 22, "rx": 462.725, "tx": 462.725, "name": "GMRS 22" }, + { "number": 23, "rx": 462.55, "tx": 467.55, "name": "GMRS 23" }, + { "number": 24, "rx": 462.575, "tx": 467.575, "name": "GMRS 24" }, + { "number": 25, "rx": 462.6, "tx": 467.6, "name": "GMRS 25" }, + { "number": 26, "rx": 462.625, "tx": 467.625, "name": "GMRS 26" }, + { "number": 27, "rx": 462.65, "tx": 467.65, "name": "GMRS 27" }, + { "number": 28, "rx": 462.675, "tx": 467.675, "name": "GMRS 28" }, + { "number": 29, "rx": 462.7, "tx": 467.7, "name": "GMRS 29" }, + { "number": 30, "rx": 462.725, "tx": 467.725, "name": "GMRS 30" } ] }, { @@ -76,11 +76,11 @@ "defaultBandwidth": "12.5kHz", "defaultMode": "Analog", "frequencies": [ - { "number": 1, "rx": 151.820, "tx": 151.820, "name": "MURS 1" }, - { "number": 2, "rx": 151.880, "tx": 151.880, "name": "MURS 2" }, - { "number": 3, "rx": 151.940, "tx": 151.940, "name": "MURS 3" }, - { "number": 4, "rx": 154.570, "tx": 154.570, "name": "MURS 4" }, - { "number": 5, "rx": 154.600, "tx": 154.600, "name": "MURS 5" } + { "number": 1, "rx": 151.82, "tx": 151.82, "name": "MURS 1" }, + { "number": 2, "rx": 151.88, "tx": 151.88, "name": "MURS 2" }, + { "number": 3, "rx": 151.94, "tx": 151.94, "name": "MURS 3" }, + { "number": 4, "rx": 154.57, "tx": 154.57, "name": "MURS 4" }, + { "number": 5, "rx": 154.6, "tx": 154.6, "name": "MURS 5" } ] }, { @@ -115,40 +115,40 @@ "defaultBandwidth": "25kHz", "defaultMode": "Analog", "frequencies": [ - { "number": 1, "rx": 146.520, "tx": 146.520, "name": "2m Calling" }, - { "number": 2, "rx": 446.000, "tx": 446.000, "name": "70cm Calling" }, - { "number": 3, "rx": 145.500, "tx": 145.500, "name": "2m APRS" }, - { "number": 4, "rx": 144.390, "tx": 144.390, "name": "2m APRS Alt" }, - { "number": 5, "rx": 146.400, "tx": 146.400, "name": "2m Simplex" }, + { "number": 1, "rx": 146.52, "tx": 146.52, "name": "2m Calling" }, + { "number": 2, "rx": 446.0, "tx": 446.0, "name": "70cm Calling" }, + { "number": 3, "rx": 145.5, "tx": 145.5, "name": "2m APRS" }, + { "number": 4, "rx": 144.39, "tx": 144.39, "name": "2m APRS Alt" }, + { "number": 5, "rx": 146.4, "tx": 146.4, "name": "2m Simplex" }, { "number": 6, "rx": 146.415, "tx": 146.415, "name": "2m Simplex" }, - { "number": 7, "rx": 146.430, "tx": 146.430, "name": "2m Simplex" }, + { "number": 7, "rx": 146.43, "tx": 146.43, "name": "2m Simplex" }, { "number": 8, "rx": 146.445, "tx": 146.445, "name": "2m Simplex" }, - { "number": 9, "rx": 146.460, "tx": 146.460, "name": "2m Simplex" }, + { "number": 9, "rx": 146.46, "tx": 146.46, "name": "2m Simplex" }, { "number": 10, "rx": 146.475, "tx": 146.475, "name": "2m Simplex" }, - { "number": 11, "rx": 146.490, "tx": 146.490, "name": "2m Simplex" }, + { "number": 11, "rx": 146.49, "tx": 146.49, "name": "2m Simplex" }, { "number": 12, "rx": 146.505, "tx": 146.505, "name": "2m Simplex" }, { "number": 13, "rx": 146.535, "tx": 146.535, "name": "2m Simplex" }, - { "number": 14, "rx": 146.550, "tx": 146.550, "name": "2m Simplex" }, + { "number": 14, "rx": 146.55, "tx": 146.55, "name": "2m Simplex" }, { "number": 15, "rx": 146.565, "tx": 146.565, "name": "2m Simplex" }, - { "number": 16, "rx": 146.580, "tx": 146.580, "name": "2m Simplex" }, + { "number": 16, "rx": 146.58, "tx": 146.58, "name": "2m Simplex" }, { "number": 17, "rx": 146.595, "tx": 146.595, "name": "2m Simplex" }, - { "number": 18, "rx": 147.420, "tx": 147.420, "name": "2m Simplex" }, + { "number": 18, "rx": 147.42, "tx": 147.42, "name": "2m Simplex" }, { "number": 19, "rx": 147.435, "tx": 147.435, "name": "2m Simplex" }, - { "number": 20, "rx": 147.450, "tx": 147.450, "name": "2m Simplex" }, + { "number": 20, "rx": 147.45, "tx": 147.45, "name": "2m Simplex" }, { "number": 21, "rx": 147.465, "tx": 147.465, "name": "2m Simplex" }, - { "number": 22, "rx": 147.480, "tx": 147.480, "name": "2m Simplex" }, + { "number": 22, "rx": 147.48, "tx": 147.48, "name": "2m Simplex" }, { "number": 23, "rx": 147.495, "tx": 147.495, "name": "2m Simplex" }, - { "number": 24, "rx": 147.510, "tx": 147.510, "name": "2m Simplex" }, + { "number": 24, "rx": 147.51, "tx": 147.51, "name": "2m Simplex" }, { "number": 25, "rx": 147.525, "tx": 147.525, "name": "2m Simplex" }, - { "number": 26, "rx": 147.540, "tx": 147.540, "name": "2m Simplex" }, + { "number": 26, "rx": 147.54, "tx": 147.54, "name": "2m Simplex" }, { "number": 27, "rx": 147.555, "tx": 147.555, "name": "2m Simplex" }, - { "number": 28, "rx": 147.570, "tx": 147.570, "name": "2m Simplex" }, + { "number": 28, "rx": 147.57, "tx": 147.57, "name": "2m Simplex" }, { "number": 29, "rx": 446.025, "tx": 446.025, "name": "70cm Simplex" }, - { "number": 30, "rx": 446.050, "tx": 446.050, "name": "70cm Simplex" }, + { "number": 30, "rx": 446.05, "tx": 446.05, "name": "70cm Simplex" }, { "number": 31, "rx": 446.075, "tx": 446.075, "name": "70cm Simplex" }, - { "number": 32, "rx": 446.100, "tx": 446.100, "name": "70cm Simplex" }, + { "number": 32, "rx": 446.1, "tx": 446.1, "name": "70cm Simplex" }, { "number": 33, "rx": 446.125, "tx": 446.125, "name": "70cm Simplex" }, - { "number": 34, "rx": 446.150, "tx": 446.150, "name": "70cm Simplex" }, + { "number": 34, "rx": 446.15, "tx": 446.15, "name": "70cm Simplex" }, { "number": 35, "rx": 446.175, "tx": 446.175, "name": "70cm Simplex" } ] }, @@ -160,65 +160,65 @@ "defaultBandwidth": "25kHz", "defaultMode": "Analog", "frequencies": [ - { "number": 1, "rx": 150.080, "tx": 150.080, "name": "RR-01" }, - { "number": 2, "rx": 150.110, "tx": 150.110, "name": "RR-02" }, - { "number": 3, "rx": 150.140, "tx": 150.140, "name": "RR-03" }, + { "number": 1, "rx": 150.08, "tx": 150.08, "name": "RR-01" }, + { "number": 2, "rx": 150.11, "tx": 150.11, "name": "RR-02" }, + { "number": 3, "rx": 150.14, "tx": 150.14, "name": "RR-03" }, { "number": 4, "rx": 150.185, "tx": 150.185, "name": "RR-04" }, - { "number": 5, "rx": 150.200, "tx": 150.200, "name": "RR-05" }, + { "number": 5, "rx": 150.2, "tx": 150.2, "name": "RR-05" }, { "number": 6, "rx": 150.245, "tx": 150.245, "name": "RR-06" }, - { "number": 7, "rx": 150.260, "tx": 150.260, "name": "RR-07" }, - { "number": 8, "rx": 150.320, "tx": 150.320, "name": "RR-08" }, + { "number": 7, "rx": 150.26, "tx": 150.26, "name": "RR-07" }, + { "number": 8, "rx": 150.32, "tx": 150.32, "name": "RR-08" }, { "number": 9, "rx": 150.365, "tx": 150.365, "name": "RR-09" }, - { "number": 10, "rx": 150.410, "tx": 150.410, "name": "RR-10" }, - { "number": 11, "rx": 150.440, "tx": 150.440, "name": "RR-11" }, - { "number": 12, "rx": 150.500, "tx": 150.500, "name": "RR-12" }, - { "number": 13, "rx": 150.530, "tx": 150.530, "name": "RR-13" }, + { "number": 10, "rx": 150.41, "tx": 150.41, "name": "RR-10" }, + { "number": 11, "rx": 150.44, "tx": 150.44, "name": "RR-11" }, + { "number": 12, "rx": 150.5, "tx": 150.5, "name": "RR-12" }, + { "number": 13, "rx": 150.53, "tx": 150.53, "name": "RR-13" }, { "number": 14, "rx": 150.545, "tx": 150.545, "name": "RR-14" }, - { "number": 15, "rx": 150.560, "tx": 150.560, "name": "RR-15" }, - { "number": 16, "rx": 150.590, "tx": 150.590, "name": "RR-16" }, - { "number": 17, "rx": 150.680, "tx": 150.680, "name": "RR-17" }, - { "number": 18, "rx": 150.710, "tx": 150.710, "name": "RR-18" }, - { "number": 19, "rx": 150.770, "tx": 150.770, "name": "RR-19" }, - { "number": 20, "rx": 150.830, "tx": 150.830, "name": "RR-20" }, - { "number": 21, "rx": 151.010, "tx": 151.010, "name": "RR-21" }, - { "number": 22, "rx": 151.130, "tx": 151.130, "name": "RR-22" }, - { "number": 23, "rx": 151.190, "tx": 151.190, "name": "RR-23" }, - { "number": 24, "rx": 151.220, "tx": 151.220, "name": "RR-24" }, - { "number": 25, "rx": 151.310, "tx": 151.310, "name": "RR-25" }, - { "number": 26, "rx": 151.340, "tx": 151.340, "name": "RR-26" }, - { "number": 27, "rx": 151.370, "tx": 151.370, "name": "RR-27" }, - { "number": 28, "rx": 151.430, "tx": 151.430, "name": "RR-28" }, - { "number": 29, "rx": 151.460, "tx": 151.460, "name": "RR-29" }, - { "number": 30, "rx": 151.490, "tx": 151.490, "name": "RR-30" }, - { "number": 31, "rx": 151.520, "tx": 151.520, "name": "RR-31" }, - { "number": 32, "rx": 151.580, "tx": 151.580, "name": "RR-32" }, - { "number": 33, "rx": 151.610, "tx": 151.610, "name": "RR-33" }, - { "number": 34, "rx": 151.640, "tx": 151.640, "name": "RR-34" }, - { "number": 35, "rx": 151.670, "tx": 151.670, "name": "RR-35" }, - { "number": 36, "rx": 151.700, "tx": 151.700, "name": "LD-1" }, + { "number": 15, "rx": 150.56, "tx": 150.56, "name": "RR-15" }, + { "number": 16, "rx": 150.59, "tx": 150.59, "name": "RR-16" }, + { "number": 17, "rx": 150.68, "tx": 150.68, "name": "RR-17" }, + { "number": 18, "rx": 150.71, "tx": 150.71, "name": "RR-18" }, + { "number": 19, "rx": 150.77, "tx": 150.77, "name": "RR-19" }, + { "number": 20, "rx": 150.83, "tx": 150.83, "name": "RR-20" }, + { "number": 21, "rx": 151.01, "tx": 151.01, "name": "RR-21" }, + { "number": 22, "rx": 151.13, "tx": 151.13, "name": "RR-22" }, + { "number": 23, "rx": 151.19, "tx": 151.19, "name": "RR-23" }, + { "number": 24, "rx": 151.22, "tx": 151.22, "name": "RR-24" }, + { "number": 25, "rx": 151.31, "tx": 151.31, "name": "RR-25" }, + { "number": 26, "rx": 151.34, "tx": 151.34, "name": "RR-26" }, + { "number": 27, "rx": 151.37, "tx": 151.37, "name": "RR-27" }, + { "number": 28, "rx": 151.43, "tx": 151.43, "name": "RR-28" }, + { "number": 29, "rx": 151.46, "tx": 151.46, "name": "RR-29" }, + { "number": 30, "rx": 151.49, "tx": 151.49, "name": "RR-30" }, + { "number": 31, "rx": 151.52, "tx": 151.52, "name": "RR-31" }, + { "number": 32, "rx": 151.58, "tx": 151.58, "name": "RR-32" }, + { "number": 33, "rx": 151.61, "tx": 151.61, "name": "RR-33" }, + { "number": 34, "rx": 151.64, "tx": 151.64, "name": "RR-34" }, + { "number": 35, "rx": 151.67, "tx": 151.67, "name": "RR-35" }, + { "number": 36, "rx": 151.7, "tx": 151.7, "name": "LD-1" }, { "number": 37, "rx": 151.745, "tx": 151.745, "name": "LD-2" }, - { "number": 38, "rx": 151.790, "tx": 151.790, "name": "LD-3" }, + { "number": 38, "rx": 151.79, "tx": 151.79, "name": "LD-3" }, { "number": 39, "rx": 151.805, "tx": 151.805, "name": "LD-4" }, - { "number": 40, "rx": 151.850, "tx": 151.850, "name": "LD-5" }, + { "number": 40, "rx": 151.85, "tx": 151.85, "name": "LD-5" }, { "number": 41, "rx": 150.485, "tx": 150.485, "name": "LD-6" }, { "number": 42, "rx": 153.215, "tx": 153.215, "name": "LD-7" }, { "number": 43, "rx": 154.665, "tx": 154.665, "name": "LD-8" }, - { "number": 44, "rx": 152.330, "tx": 152.330, "name": "LD-9" }, + { "number": 44, "rx": 152.33, "tx": 152.33, "name": "LD-9" }, { "number": 45, "rx": 153.635, "tx": 153.635, "name": "LD-10" }, - { "number": 46, "rx": 157.590, "tx": 157.590, "name": "LD-11" }, - { "number": 47, "rx": 159.750, "tx": 159.750, "name": "LD-12" }, - { "number": 48, "rx": 164.010, "tx": 164.010, "name": "LD-13" }, - { "number": 49, "rx": 165.960, "tx": 165.960, "name": "LD-14" }, - { "number": 50, "rx": 154.100, "tx": 154.100, "name": "LADD-1" }, - { "number": 51, "rx": 158.940, "tx": 158.940, "name": "LADD-2" }, + { "number": 46, "rx": 157.59, "tx": 157.59, "name": "LD-11" }, + { "number": 47, "rx": 159.75, "tx": 159.75, "name": "LD-12" }, + { "number": 48, "rx": 164.01, "tx": 164.01, "name": "LD-13" }, + { "number": 49, "rx": 165.96, "tx": 165.96, "name": "LD-14" }, + { "number": 50, "rx": 154.1, "tx": 154.1, "name": "LADD-1" }, + { "number": 51, "rx": 158.94, "tx": 158.94, "name": "LADD-2" }, { "number": 52, "rx": 154.325, "tx": 154.325, "name": "LADD-3" }, - { "number": 53, "rx": 173.370, "tx": 173.370, "name": "LADD-4" }, - { "number": 54, "rx": 150.470, "tx": 150.470, "name": "AB-IND-150.470" }, + { "number": 53, "rx": 173.37, "tx": 173.37, "name": "LADD-4" }, + { "number": 54, "rx": 150.47, "tx": 150.47, "name": "AB-IND-150.470" }, { "number": 55, "rx": 151.625, "tx": 151.625, "name": "AB-IND-151.625" }, - { "number": 56, "rx": 152.900, "tx": 152.900, "name": "AB-IND-152.900" }, - { "number": 57, "rx": 152.960, "tx": 152.960, "name": "AB-IND-152.960" }, + { "number": 56, "rx": 152.9, "tx": 152.9, "name": "AB-IND-152.900" }, + { "number": 57, "rx": 152.96, "tx": 152.96, "name": "AB-IND-152.960" }, { "number": 58, "rx": 150.155, "tx": 150.155, "name": "SK-RES-150.155" }, - { "number": 59, "rx": 151.040, "tx": 151.040, "name": "SK-RES-151.040" }, + { "number": 59, "rx": 151.04, "tx": 151.04, "name": "SK-RES-151.040" }, { "number": 60, "rx": 151.265, "tx": 151.265, "name": "SK-RES-151.265" } ] }, @@ -229,73 +229,73 @@ "defaultBandwidth": "25kHz", "defaultMode": "Analog", "frequencies": [ - { "number": 1, "rx": 162.400, "tx": 162.400, "name": "WX-1" }, - { "number": 2, "rx": 162.425, "tx": 162.425, "name": "WX-2" }, - { "number": 3, "rx": 162.450, "tx": 162.450, "name": "WX-3" }, - { "number": 4, "rx": 162.475, "tx": 162.475, "name": "WX-4" }, - { "number": 5, "rx": 162.500, "tx": 162.500, "name": "WX-5" }, - { "number": 6, "rx": 162.525, "tx": 162.525, "name": "WX-6" }, - { "number": 7, "rx": 162.550, "tx": 162.550, "name": "WX-7" } + { "number": 1, "rx": 162.55, "tx": 162.55, "name": "WX-1" }, + { "number": 2, "rx": 162.4, "tx": 162.4, "name": "WX-2" }, + { "number": 3, "rx": 162.475, "tx": 162.475, "name": "WX-3" }, + { "number": 4, "rx": 162.425, "tx": 162.425, "name": "WX-4" }, + { "number": 5, "rx": 162.45, "tx": 162.45, "name": "WX-5" }, + { "number": 6, "rx": 162.5, "tx": 162.5, "name": "WX-6" }, + { "number": 7, "rx": 162.525, "tx": 162.525, "name": "WX-7" } ] }, { "name": "Marine", - "description": "Marine VHF radio channels for boating communication", + "description": "Marine VHF channels, US/Canada plan - duplex channels RX on the coast-station side", "defaultPower": "High", "defaultBandwidth": "25kHz", "defaultMode": "Analog", "frequencies": [ - { "number": 1, "rx": 156.050, "tx": 156.050, "name": "MARINE-01" }, - { "number": 2, "rx": 156.100, "tx": 156.100, "name": "MARINE-02" }, - { "number": 3, "rx": 156.150, "tx": 156.150, "name": "MARINE-03" }, - { "number": 4, "rx": 156.200, "tx": 156.200, "name": "MARINE-04" }, - { "number": 5, "rx": 156.250, "tx": 156.250, "name": "MARINE-05" }, - { "number": 6, "rx": 156.300, "tx": 156.300, "name": "MARINE-06" }, - { "number": 7, "rx": 156.350, "tx": 156.350, "name": "MARINE-07" }, - { "number": 8, "rx": 156.400, "tx": 156.400, "name": "MARINE-08" }, - { "number": 9, "rx": 156.450, "tx": 156.450, "name": "MARINE-09" }, - { "number": 10, "rx": 156.500, "tx": 156.500, "name": "MARINE-10" }, - { "number": 11, "rx": 156.550, "tx": 156.550, "name": "MARINE-11" }, - { "number": 12, "rx": 156.600, "tx": 156.600, "name": "MARINE-12" }, - { "number": 13, "rx": 156.650, "tx": 156.650, "name": "MARINE-13" }, - { "number": 14, "rx": 156.700, "tx": 156.700, "name": "MARINE-14" }, - { "number": 15, "rx": 156.750, "tx": 156.750, "name": "MARINE-15" }, - { "number": 16, "rx": 156.800, "tx": 156.800, "name": "MARINE-16 EMERG" }, - { "number": 17, "rx": 156.850, "tx": 156.850, "name": "MARINE-17" }, - { "number": 18, "rx": 156.900, "tx": 156.900, "name": "MARINE-18" }, - { "number": 19, "rx": 156.950, "tx": 156.950, "name": "MARINE-19" }, - { "number": 20, "rx": 157.000, "tx": 157.000, "name": "MARINE-20" }, - { "number": 21, "rx": 157.050, "tx": 157.050, "name": "MARINE-21" }, - { "number": 22, "rx": 157.100, "tx": 157.100, "name": "MARINE-22" }, - { "number": 23, "rx": 157.150, "tx": 157.150, "name": "MARINE-23" }, - { "number": 24, "rx": 157.200, "tx": 157.200, "name": "MARINE-24" }, - { "number": 25, "rx": 157.250, "tx": 157.250, "name": "MARINE-25" }, - { "number": 26, "rx": 157.300, "tx": 157.300, "name": "MARINE-26" }, - { "number": 27, "rx": 157.350, "tx": 157.350, "name": "MARINE-27" }, - { "number": 28, "rx": 157.400, "tx": 157.400, "name": "MARINE-28" }, - { "number": 29, "rx": 157.450, "tx": 157.450, "name": "MARINE-29" }, - { "number": 30, "rx": 157.500, "tx": 157.500, "name": "MARINE-30" }, - { "number": 31, "rx": 157.550, "tx": 157.550, "name": "MARINE-31" }, - { "number": 32, "rx": 157.600, "tx": 157.600, "name": "MARINE-32" }, - { "number": 33, "rx": 157.650, "tx": 157.650, "name": "MARINE-33" }, - { "number": 34, "rx": 157.700, "tx": 157.700, "name": "MARINE-34" }, - { "number": 35, "rx": 157.750, "tx": 157.750, "name": "MARINE-35" }, - { "number": 36, "rx": 157.800, "tx": 157.800, "name": "MARINE-36" }, - { "number": 37, "rx": 157.850, "tx": 157.850, "name": "MARINE-37" }, - { "number": 38, "rx": 157.900, "tx": 157.900, "name": "MARINE-38" }, - { "number": 39, "rx": 157.950, "tx": 157.950, "name": "MARINE-39" }, - { "number": 40, "rx": 158.000, "tx": 158.000, "name": "MARINE-40" }, - { "number": 41, "rx": 158.050, "tx": 158.050, "name": "MARINE-41" }, - { "number": 42, "rx": 158.100, "tx": 158.100, "name": "MARINE-42" }, - { "number": 43, "rx": 158.150, "tx": 158.150, "name": "MARINE-43" }, - { "number": 44, "rx": 158.200, "tx": 158.200, "name": "MARINE-44" }, - { "number": 45, "rx": 158.250, "tx": 158.250, "name": "MARINE-45" }, - { "number": 46, "rx": 158.300, "tx": 158.300, "name": "MARINE-46" }, - { "number": 47, "rx": 158.350, "tx": 158.350, "name": "MARINE-47" }, - { "number": 48, "rx": 158.400, "tx": 158.400, "name": "MARINE-48" }, - { "number": 49, "rx": 158.450, "tx": 158.450, "name": "MARINE-49" }, - { "number": 50, "rx": 158.500, "tx": 158.500, "name": "MARINE-50" } + { "number": 1, "rx": 156.05, "tx": 156.05, "name": "M01A", "notes": "VTS/port ops" }, + { "number": 2, "rx": 156.25, "tx": 156.25, "name": "M05A", "notes": "VTS" }, + { "number": 3, "rx": 156.3, "tx": 156.3, "name": "M06 SAFETY", "notes": "Intership safety" }, + { "number": 4, "rx": 156.35, "tx": 156.35, "name": "M07A", "notes": "Commercial" }, + { "number": 5, "rx": 156.4, "tx": 156.4, "name": "M08", "notes": "Commercial intership" }, + { "number": 6, "rx": 156.45, "tx": 156.45, "name": "M09 CALLING", "notes": "Boater calling" }, + { "number": 7, "rx": 156.5, "tx": 156.5, "name": "M10", "notes": "Commercial" }, + { "number": 8, "rx": 156.55, "tx": 156.55, "name": "M11", "notes": "VTS" }, + { "number": 9, "rx": 156.6, "tx": 156.6, "name": "M12", "notes": "Port ops/VTS" }, + { "number": 10, "rx": 156.65, "tx": 156.65, "name": "M13 BRIDGE", "notes": "Bridge-to-bridge, 1W" }, + { "number": 11, "rx": 156.7, "tx": 156.7, "name": "M14", "notes": "Port ops/VTS" }, + { "number": 12, "rx": 156.75, "tx": 156.75, "name": "M15", "notes": "1W" }, + { "number": 13, "rx": 156.8, "tx": 156.8, "name": "M16 DISTRESS", "notes": "Distress/safety/calling" }, + { "number": 14, "rx": 156.85, "tx": 156.85, "name": "M17", "notes": "1W" }, + { "number": 15, "rx": 156.9, "tx": 156.9, "name": "M18A", "notes": "Commercial" }, + { "number": 16, "rx": 156.95, "tx": 156.95, "name": "M19A CCG", "notes": "Canadian Coast Guard" }, + { "number": 17, "rx": 161.6, "tx": 157.0, "name": "M20 PORT", "notes": "Duplex port ops; RX=coast side" }, + { "number": 18, "rx": 157.05, "tx": 157.05, "name": "M21A CG", "notes": "Coast Guard" }, + { "number": 19, "rx": 157.1, "tx": 157.1, "name": "M22A CG", "notes": "Coast Guard liaison/broadcasts" }, + { "number": 20, "rx": 157.15, "tx": 157.15, "name": "M23A CG", "notes": "Coast Guard" }, + { "number": 21, "rx": 161.8, "tx": 157.2, "name": "M24 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 22, "rx": 161.85, "tx": 157.25, "name": "M25 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 23, "rx": 161.9, "tx": 157.3, "name": "M26 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 24, "rx": 161.95, "tx": 157.35, "name": "M27 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 25, "rx": 162.0, "tx": 157.4, "name": "M28 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 26, "rx": 160.625, "tx": 156.025, "name": "M60 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 27, "rx": 156.075, "tx": 156.075, "name": "M61A CCG", "notes": "Canadian Coast Guard" }, + { "number": 28, "rx": 156.125, "tx": 156.125, "name": "M62A CCG", "notes": "Canadian Coast Guard" }, + { "number": 29, "rx": 156.175, "tx": 156.175, "name": "M63A", "notes": "VTS" }, + { "number": 30, "rx": 156.225, "tx": 156.225, "name": "M64A" }, + { "number": 31, "rx": 156.275, "tx": 156.275, "name": "M65A", "notes": "Port ops" }, + { "number": 32, "rx": 156.325, "tx": 156.325, "name": "M66A", "notes": "Port ops" }, + { "number": 33, "rx": 156.375, "tx": 156.375, "name": "M67" }, + { "number": 34, "rx": 156.425, "tx": 156.425, "name": "M68 REC", "notes": "Recreational working" }, + { "number": 35, "rx": 156.475, "tx": 156.475, "name": "M69 REC", "notes": "Recreational working" }, + { "number": 36, "rx": 156.575, "tx": 156.575, "name": "M71 REC", "notes": "Recreational working" }, + { "number": 37, "rx": 156.625, "tx": 156.625, "name": "M72 REC", "notes": "Recreational intership" }, + { "number": 38, "rx": 156.675, "tx": 156.675, "name": "M73 REC", "notes": "Recreational working" }, + { "number": 39, "rx": 156.725, "tx": 156.725, "name": "M74", "notes": "Port ops" }, + { "number": 40, "rx": 156.875, "tx": 156.875, "name": "M77", "notes": "1W" }, + { "number": 41, "rx": 156.925, "tx": 156.925, "name": "M78A REC", "notes": "Recreational working" }, + { "number": 42, "rx": 156.975, "tx": 156.975, "name": "M79A" }, + { "number": 43, "rx": 157.025, "tx": 157.025, "name": "M80A" }, + { "number": 44, "rx": 157.075, "tx": 157.075, "name": "M81A CCG", "notes": "Canadian Coast Guard" }, + { "number": 45, "rx": 157.125, "tx": 157.125, "name": "M82A CCG", "notes": "Canadian Coast Guard" }, + { "number": 46, "rx": 157.175, "tx": 157.175, "name": "M83A CCG", "notes": "Canadian Coast Guard" }, + { "number": 47, "rx": 161.825, "tx": 157.225, "name": "M84 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 48, "rx": 161.875, "tx": 157.275, "name": "M85 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 49, "rx": 161.925, "tx": 157.325, "name": "M86 MAROP", "notes": "Duplex marine operator; RX=coast side" }, + { "number": 50, "rx": 157.375, "tx": 157.375, "name": "M87" }, + { "number": 51, "rx": 157.425, "tx": 157.425, "name": "M88", "notes": "Commercial intership" } ] } ] - From 8e56ec21939750c469bebe57fb73378ea030b14b Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Wed, 8 Jul 2026 00:33:50 -0700 Subject: [PATCH 3/6] Fix some ft65r issues with settings --- src/hooks/useRadioConnection.ts | 67 +++++++++++++++++++++++++++------ src/radios/ft65/protocol.ts | 27 +++++++++++-- src/radios/shared/serialPort.ts | 11 ++++++ src/store/radioStore.ts | 8 ++++ src/types/radio.ts | 8 ++++ 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/src/hooks/useRadioConnection.ts b/src/hooks/useRadioConnection.ts index 7914c44..cc0f3f0 100644 --- a/src/hooks/useRadioConnection.ts +++ b/src/hooks/useRadioConnection.ts @@ -52,7 +52,7 @@ export function useRadioConnection() { const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); - const { selectedRadioModel, preferredTransport, radioInfo, setConnected, setRadioInfo, setRawRadioSettingsData, setRawContactBlockData, setRawContactBlocks, setBlockMetadata, setBlockData, setWriteBlockData, setZoneComparisonData, setBootImageRaw, setBootImageDescription, setConnectionError } = useRadioStore(); + const { selectedRadioModel, preferredTransport, radioInfo, setConnected, setRadioInfo, setRawRadioSettingsData, setRawContactBlockData, setRawContactBlocks, setBlockMetadata, setBlockData, setCachedMemoryImage, setWriteBlockData, setZoneComparisonData, setBootImageRaw, setBootImageDescription, setConnectionError } = useRadioStore(); const { setChannels, setRawChannelData } = useChannelsStore(); const { setZones, setRawZoneData } = useZonesStore(); const { setScanLists, setRawScanListData } = useScanListsStore(); @@ -104,6 +104,7 @@ export function useRadioConnection() { setAnalogEmergencies([]); setBlockMetadata(new Map()); setBlockData(new Map()); + setCachedMemoryImage(null); setRawRadioSettingsData(null); let protocol: RadioProtocol | null = null; @@ -238,6 +239,22 @@ export function useRadioConnection() { } catch { console.warn('Error reading configuration blocks'); } proto.onProgress = savedProgress; + + // Persist the clone-radio memory image (FT-65 family) so a later write — + // which runs on a fresh protocol instance — can preserve non-channel + // regions (settings, DTMF, P-keys) instead of writing zeros. + const memoryImage = proto.getMemoryImage?.(); + if (memoryImage) { + setCachedMemoryImage({ model: info.model, image: memoryImage }); + } + + // Close the connection — everything past this point parses from cache. + // DM-32 already disconnected itself after its bulk read (disconnect is + // idempotent); for clone radios this releases the port's stream locks so + // the next operation can reopen the port instead of hitting + // InvalidStateError ("port already open"). + try { await proto.disconnect(); } catch { /* already closed */ } + onProgress?.(100, 'Read complete!', steps[5]); }; @@ -298,7 +315,7 @@ export function useRadioConnection() { document.removeEventListener('visibilitychange', onVisibilityChange); if (!error) setIsConnecting(false); } - }, [selectedRadioModel, preferredTransport, setConnected, setRadioInfo, setRawRadioSettingsData, setChannels, setZones, setScanLists, setContacts, setContactsLoaded, setRawChannelData, setRawZoneData, setRawScanListData, setBlockMetadata, setBlockData, setRadioSettings, setDigitalEmergencies, setDigitalEmergencyConfig, setAnalogEmergencies, setMessages, setRawMessageData, setMessagesLoaded, setQuickContacts, setQuickContactsLoaded, setRadioIds, setRawRadioIdData, setRadioIdsLoaded, setCalibration, setCalibrationLoaded, setRXGroups, setRawGroupData, setGroupsLoaded, setConnectionError]); + }, [selectedRadioModel, preferredTransport, setConnected, setRadioInfo, setRawRadioSettingsData, setChannels, setZones, setScanLists, setContacts, setContactsLoaded, setRawChannelData, setRawZoneData, setRawScanListData, setBlockMetadata, setBlockData, setCachedMemoryImage, setRadioSettings, setDigitalEmergencies, setDigitalEmergencyConfig, setAnalogEmergencies, setMessages, setRawMessageData, setMessagesLoaded, setQuickContacts, setQuickContactsLoaded, setRadioIds, setRawRadioIdData, setRadioIdsLoaded, setCalibration, setCalibrationLoaded, setRXGroups, setRawGroupData, setGroupsLoaded, setConnectionError]); const readContacts = useCallback(async ( onProgress?: (progress: number, message: string) => void @@ -568,6 +585,14 @@ export function useRadioConnection() { } else { console.warn('[Connection] Store cache is empty - will need to read all blocks from radio'); } + } else if (protocol.setMemoryImage) { + // Clone radios (FT-65 family): restore the memory image from the last read + // so the full-image write preserves non-channel regions. Only restore an + // image that came from the same model — never write one radio's image to another. + const { cachedMemoryImage } = useRadioStore.getState(); + if (cachedMemoryImage && cachedMemoryImage.model === effectiveModel) { + protocol.setMemoryImage(cachedMemoryImage.image); + } } // Set up progress callback that forwards to our callback @@ -589,13 +614,29 @@ export function useRadioConnection() { setRadioInfo(connectedRadioInfo); setConnected(true); + // Settings state is needed before the channel write: buffered-settings + // protocols (Yaesu clone) flush settings into the memory image that + // writeChannels uploads, so they must be staged first or they are never sent. + const radioSettingsStore = useRadioSettingsStore.getState(); + const radioSettings = radioSettingsStore.settings; + const changedFields = radioSettingsStore.getChangedFields(); + const hasSettingsToWrite = radioSettings != null && changedFields.length > 0; + // Step 4: Write channels (and zones/scan lists for DM-32; analog radios use writeChannels only) if (dm32) { onProgress?.(20, 'Writing channels, zones, and scan lists to radio...', steps[4]); await dm32.writeAllData(validChannels, filteredZones, filteredScanLists); } else { + if (hasSettingsToWrite && protocol.bufferedSettingsWrite) { + onProgress?.(15, `Staging ${changedFields.length} changed setting(s)...`, steps[4]); + await protocol.writeRadioSettings(radioSettings, { changedFields }); + } onProgress?.(20, 'Writing channels to radio...', steps[4]); await protocol.writeChannels(validChannels); + if (hasSettingsToWrite && protocol.bufferedSettingsWrite) { + // The channel write uploaded the image containing the staged settings. + radioSettingsStore.clearChanges(); + } } if (dm32) { @@ -649,23 +690,27 @@ export function useRadioConnection() { } } - // Step 6: Write radio settings only if they have been modified (UV5R-Mini and DM-32) - const radioSettingsStore = useRadioSettingsStore.getState(); - const radioSettings = radioSettingsStore.settings; - const changedFields = radioSettingsStore.getChangedFields(); - const hasSettingsToWrite = radioSettings && changedFields.length > 0; - - if (hasSettingsToWrite) { + // Step 6: Write radio settings if modified — direct-write protocols only + // (DM-32, UV5R-Mini); buffered-settings protocols were handled with the + // channel write above. + if (hasSettingsToWrite && !protocol.bufferedSettingsWrite) { onProgress?.(95, `Writing ${changedFields.length} changed setting(s) to radio...`, steps[4]); await protocol.writeRadioSettings(radioSettings, { changedFields }); // Clear changes after successful write radioSettingsStore.clearChanges(); } - + // Store write block data and zone comparison data for debug export (DM-32 only) if (dm32) { setWriteBlockData(dm32.writeBlockData); setZoneComparisonData(dm32.zoneComparisonData); + } else { + // Persist the just-written image as the new baseline for this session + // (the write may have flushed settings changes into it). + const writtenImage = protocol.getMemoryImage?.(); + if (writtenImage) { + setCachedMemoryImage({ model: connectedRadioInfo.model, image: writtenImage }); + } } // Step 6: Disconnect @@ -725,7 +770,7 @@ export function useRadioConnection() { setIsConnecting(false); } } - }, [radioInfo, setConnected, setRadioInfo, setWriteBlockData, setZoneComparisonData, setConnectionError]); + }, [radioInfo, selectedRadioModel, setConnected, setRadioInfo, setCachedMemoryImage, setWriteBlockData, setZoneComparisonData, setConnectionError]); return { isConnecting, diff --git a/src/radios/ft65/protocol.ts b/src/radios/ft65/protocol.ts index 77addc6..263861f 100644 --- a/src/radios/ft65/protocol.ts +++ b/src/radios/ft65/protocol.ts @@ -17,6 +17,10 @@ import { parseAllChannels, encodeChannel, clearChannelRegions } from './structur import { parseFt65Settings, writeFt65Settings } from './settingsFormat'; export class FT65Protocol extends BaseAnalogProtocol { + /** Settings are buffered into the memory image and uploaded by writeChannels — + * the connection hook must call writeRadioSettings before writeChannels. */ + readonly bufferedSettingsWrite = true; + private conn: FT65Connection | null = null; private port: FT65SerialPort | null = null; private cachedImage: Uint8Array | null = null; @@ -92,14 +96,25 @@ export class FT65Protocol extends BaseAnalogProtocol { return parseAllChannels(image, this.offsetFactor); } + getMemoryImage(): Uint8Array | null { + return this.cachedImage; + } + + setMemoryImage(image: Uint8Array): void { + this.cachedImage = new Uint8Array(image); + } + async writeChannels(channels: Channel[]): Promise { if (!this.conn) throw new Error('Not connected'); + if (!this.cachedImage) { + // The write uploads a full memory image; without a read image every + // non-channel byte (settings, DTMF, P-keys) would be written as zero. + throw new Error('Read the radio first. Writing needs the memory image from a read to preserve radio settings.'); + } - const image = new Uint8Array(FT65_MEM_SIZE); // Start from the cached read image so settings/DTMF/P-keys are preserved - if (this.cachedImage) { - image.set(this.cachedImage); - } + const image = new Uint8Array(FT65_MEM_SIZE); + image.set(this.cachedImage); // Flush any pending settings changes into the image before writing if (this.pendingSettings) { @@ -133,6 +148,10 @@ export class FT65Protocol extends BaseAnalogProtocol { } await this.conn.sendEnd(); + + // The uploaded image is the radio's new state — it becomes the cache baseline + // for subsequent writes in this session. + this.cachedImage = image; } override async readRadioSettings(): Promise { diff --git a/src/radios/shared/serialPort.ts b/src/radios/shared/serialPort.ts index 4fde23c..919e37b 100644 --- a/src/radios/shared/serialPort.ts +++ b/src/radios/shared/serialPort.ts @@ -14,6 +14,17 @@ export async function requestSerialPort( const port: SerialLikePort = forceSelection ? await nav.requestPort() : ((await nav.getPorts())[0] ?? (await nav.requestPort())); + + // The port may still be open from a previous operation (e.g. a read that + // errored mid-transfer). Reuse it if its streams are free; calling open() + // on an already-open port throws InvalidStateError. + if (port.readable && port.writable) { + if (port.readable.locked || port.writable.locked) { + throw new Error('Serial port is busy from a previous operation. Reconnect the cable or reload the page.'); + } + return port; + } + await port.open({ baudRate }); return port; } diff --git a/src/store/radioStore.ts b/src/store/radioStore.ts index c818d3a..a866629 100644 --- a/src/store/radioStore.ts +++ b/src/store/radioStore.ts @@ -39,6 +39,11 @@ interface RadioState { rawContactBlocks: Map; blockMetadata: Map; blockData: Map; + /** Full memory image from the last read of a clone-style radio (FT-65 family). + * Restored into the fresh protocol instance on write so non-channel regions + * (settings, DTMF, P-keys) survive the read→write cycle. Tagged with the + * model it came from so it is never written to a different radio. */ + cachedMemoryImage: { model: string; image: Uint8Array } | null; writeBlockData: Map; zoneComparisonData: ZoneComparisonData; bootImageRaw: Uint8Array | null; @@ -52,6 +57,7 @@ interface RadioState { setRawContactBlocks: (blocks: Map) => void; setBlockMetadata: (metadata: Map) => void; setBlockData: (data: Map) => void; + setCachedMemoryImage: (entry: { model: string; image: Uint8Array } | null) => void; setWriteBlockData: (data: Map) => void; setZoneComparisonData: (data: ZoneComparisonData) => void; setBootImageRaw: (data: Uint8Array | null) => void; @@ -75,6 +81,7 @@ export const useRadioStore = create((set) => ({ rawContactBlocks: new Map(), blockMetadata: new Map(), blockData: new Map(), + cachedMemoryImage: null, writeBlockData: new Map(), zoneComparisonData: [], bootImageRaw: null, @@ -88,6 +95,7 @@ export const useRadioStore = create((set) => ({ setRawContactBlocks: (blocks) => set({ rawContactBlocks: blocks }), setBlockMetadata: (metadata) => set({ blockMetadata: metadata }), setBlockData: (data) => set({ blockData: data }), + setCachedMemoryImage: (entry) => set({ cachedMemoryImage: entry }), setWriteBlockData: (data) => set({ writeBlockData: data }), setZoneComparisonData: (data) => set({ zoneComparisonData: data }), setBootImageRaw: (data) => set({ bootImageRaw: data }), diff --git a/src/types/radio.ts b/src/types/radio.ts index d68266d..9383fb5 100644 --- a/src/types/radio.ts +++ b/src/types/radio.ts @@ -144,4 +144,12 @@ export interface RadioProtocol { writeRadioSettings(settings: RadioSettings, options?: { changedFields?: string[] }): Promise; onProgress?: (progress: number, message: string) => void; getFirmwareFromCache?(): string | null; + /** Clone-style radios: full memory image cached by the last read/write. */ + getMemoryImage?(): Uint8Array | null; + /** Clone-style radios: restore a previously read memory image into a fresh instance before writing. */ + setMemoryImage?(image: Uint8Array): void; + /** True when writeRadioSettings only buffers changes into the memory image that the + * next writeChannels uploads (Yaesu clone protocol). The connection hook must call + * writeRadioSettings BEFORE writeChannels for these protocols. */ + readonly bufferedSettingsWrite?: boolean; } From 00f04fd05a716e71f95e8ad45cb5e06dac725a7d Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Wed, 8 Jul 2026 00:46:39 -0700 Subject: [PATCH 4/6] More fixes --- src/hooks/useRadioConnection.ts | 8 ++----- src/services/csv/csvImporter.ts | 6 ++++- tests/unit/csvImporter.test.ts | 21 +++++++++++++++++ tests/unit/ft65Protocol.test.ts | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 tests/unit/ft65Protocol.test.ts diff --git a/src/hooks/useRadioConnection.ts b/src/hooks/useRadioConnection.ts index cc0f3f0..4849471 100644 --- a/src/hooks/useRadioConnection.ts +++ b/src/hooks/useRadioConnection.ts @@ -313,7 +313,7 @@ export function useRadioConnection() { throw err; } finally { document.removeEventListener('visibilitychange', onVisibilityChange); - if (!error) setIsConnecting(false); + setIsConnecting(false); } }, [selectedRadioModel, preferredTransport, setConnected, setRadioInfo, setRawRadioSettingsData, setChannels, setZones, setScanLists, setContacts, setContactsLoaded, setRawChannelData, setRawZoneData, setRawScanListData, setBlockMetadata, setBlockData, setCachedMemoryImage, setRadioSettings, setDigitalEmergencies, setDigitalEmergencyConfig, setAnalogEmergencies, setMessages, setRawMessageData, setMessagesLoaded, setQuickContacts, setQuickContactsLoaded, setRadioIds, setRawRadioIdData, setRadioIdsLoaded, setCalibration, setCalibrationLoaded, setRXGroups, setRawGroupData, setGroupsLoaded, setConnectionError]); @@ -764,11 +764,7 @@ export function useRadioConnection() { throw err; } finally { document.removeEventListener('visibilitychange', onVisibilityChange); - // Only set connecting to false if we didn't already (success case) - // On error, we set it in the catch block so modal stays open to show error - if (!error) { - setIsConnecting(false); - } + setIsConnecting(false); } }, [radioInfo, selectedRadioModel, setConnected, setRadioInfo, setCachedMemoryImage, setWriteBlockData, setZoneComparisonData, setConnectionError]); diff --git a/src/services/csv/csvImporter.ts b/src/services/csv/csvImporter.ts index 16ac372..eff2db4 100644 --- a/src/services/csv/csvImporter.ts +++ b/src/services/csv/csvImporter.ts @@ -37,7 +37,11 @@ export function parseCSV(content: string): string[][] { } export function getValue(headers: string[], row: string[], headerName: string): string { - const index = headers.findIndex(h => h.includes(headerName.toLowerCase())); + const name = headerName.toLowerCase(); + // Exact match wins; partial match alone would let 'ptt id' hit the earlier + // 'ptt id display' column. Partial stays as fallback ('id' → 'dmr id'). + let index = headers.indexOf(name); + if (index < 0) index = headers.findIndex(h => h.includes(name)); return index >= 0 && index < row.length ? row[index].trim() : ''; } diff --git a/tests/unit/csvImporter.test.ts b/tests/unit/csvImporter.test.ts index af943c4..6ec83a3 100644 --- a/tests/unit/csvImporter.test.ts +++ b/tests/unit/csvImporter.test.ts @@ -68,6 +68,16 @@ describe('getValue', () => { expect(getValue(headers, row, 'rx')).toBe('146.520'); }); + it('prefers an exact header match over an earlier partial match', () => { + // Export order puts 'ptt id display' before 'ptt id' — partial matching + // alone would return the display column for 'ptt id'. + const h = ['ptt id display', 'ptt id', 'ptt id type']; + const r = ['Yes', '42', 'BOT']; + expect(getValue(h, r, 'ptt id')).toBe('42'); + expect(getValue(h, r, 'ptt id display')).toBe('Yes'); + expect(getValue(h, r, 'ptt id type')).toBe('BOT'); + }); + it('returns empty string when header not found', () => { expect(getValue(headers, row, 'nonexistent')).toBe(''); }); @@ -162,6 +172,17 @@ describe('importChannelsFromCSV', () => { expect(ch.mode).toBe('Analog'); }); + it('imports pttId despite the PTT ID Display column exported before it', () => { + const csv = 'Name,RX Frequency,TX Frequency,PTT ID Display,PTT ID,PTT ID Type\n' + + 'Chan,146.520,146.520,Yes,42,BOT'; + const result = importChannelsFromCSV(csv); + expect(result.success).toBe(true); + const ch = result.channels![0]; + expect(ch.pttId).toBe(42); + expect(ch.pttIdDisplay).toBe(true); + expect(ch.pttIdType).toBe('BOT'); + }); + it('falls back to row index when Channel Number is missing', () => { const csv = `Name,RX Frequency,TX Frequency\nMy Chan,146.520,146.520`; const result = importChannelsFromCSV(csv); diff --git a/tests/unit/ft65Protocol.test.ts b/tests/unit/ft65Protocol.test.ts new file mode 100644 index 0000000..97619c6 --- /dev/null +++ b/tests/unit/ft65Protocol.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { FT65Protocol } from '../../src/radios/ft65/protocol'; +import { ID_PREFIX_FT65, OFFSET_FACTOR_FT65, MAX_NAME_LEN_FT65 } from '../../src/radios/ft65/constants'; + +function makeProtocol(): FT65Protocol { + return new FT65Protocol('FT-65', [ID_PREFIX_FT65], OFFSET_FACTOR_FT65, MAX_NAME_LEN_FT65); +} + +// The connection hook relies on this surface to fix two write-path bugs: +// bufferedSettingsWrite makes it stage settings BEFORE writeChannels, and +// get/setMemoryImage carry the read image across protocol instances so a +// write doesn't zero the radio's non-channel memory. +describe('FT65Protocol memory image handling', () => { + it('declares bufferedSettingsWrite so the hook stages settings before writeChannels', () => { + expect(makeProtocol().bufferedSettingsWrite).toBe(true); + }); + + it('has no memory image before a read', () => { + expect(makeProtocol().getMemoryImage()).toBeNull(); + }); + + it('setMemoryImage stores a defensive copy retrievable via getMemoryImage', () => { + const proto = makeProtocol(); + const image = new Uint8Array([1, 2, 3, 4]); + proto.setMemoryImage(image); + + const cached = proto.getMemoryImage(); + expect(cached).toEqual(image); + expect(cached).not.toBe(image); // mutation of the caller's array must not affect the cache + + image[0] = 99; + expect(proto.getMemoryImage()![0]).toBe(1); + }); + + it('refuses to write channels without a memory image (would zero radio settings)', async () => { + const proto = makeProtocol(); + // Bypass the connection guard to reach the image guard. + (proto as unknown as { conn: object }).conn = {}; + await expect(proto.writeChannels([])).rejects.toThrow(/Read the radio first/); + }); +}); From 765d696d1be24d186b507c8fef7a12e4d4a1a3ec Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Wed, 8 Jul 2026 01:01:53 -0700 Subject: [PATCH 5/6] Fix the weird zone scramble issue that happens with channel wizard sometime --- .../import/sources/FixedChannelsSource.tsx | 61 +++++-------------- src/components/import/sources/RptrsSource.tsx | 34 ++++++++--- src/services/channelMerger.ts | 50 +++++++++++++++ tests/unit/channelMerger.test.ts | 57 ++++++++++++++++- 4 files changed, 147 insertions(+), 55 deletions(-) diff --git a/src/components/import/sources/FixedChannelsSource.tsx b/src/components/import/sources/FixedChannelsSource.tsx index 48eff2f..31ac74c 100644 --- a/src/components/import/sources/FixedChannelsSource.tsx +++ b/src/components/import/sources/FixedChannelsSource.tsx @@ -3,7 +3,7 @@ import { formatPlural } from '../../../utils/formatPlural'; import { useImportStores } from '../../../hooks/useImportStores'; import { getNextChannelNumber } from '../../../utils/importHelpers'; import { getAvailableFixedChannelSets, getChannelsForSet } from '../../../services/fixedChannels'; -import { mergeOverlappingChannels, getChannelFullKey } from '../../../services/channelMerger'; +import { mergeChannelSetsWithExisting } from '../../../services/channelMerger'; import { generateZoneId } from '../../../utils/zoneHelpers'; import type { Channel } from '../../../models'; import type { Zone } from '../../../models'; @@ -40,60 +40,31 @@ export const FixedChannelsSource: React.FC = ({ try { const nextChannelNumber = getNextChannelNumber(channels); - // Generate channels for each selected set (with temporary numbers) + // Generate channels for each selected set. Each set gets a distinct + // temporary number range — the merge mapping is keyed by these numbers, + // so ranges that overlap between sets would clobber each other and + // scramble the zones built below. const channelSets: Channel[][] = []; const setNames: string[] = []; + let tempNumber = 1; for (const setName of selectedFixedSets) { - // Use generic function to get channels for any set - const setChannels = getChannelsForSet(setName, 1); + const setChannels = getChannelsForSet(setName, tempNumber); if (setChannels.length > 0) { channelSets.push(setChannels); setNames.push(setName); + tempNumber += setChannels.length; } } - // FIRST: Check against existing channels and build mapping for duplicates - // Match on ALL settings: frequency, name, mode, bandwidth, power, CTCSS/DCS - const existingChannelMap = new Map(); // full key -> channel number - for (const ch of channels) { - const fullKey = getChannelFullKey(ch); - existingChannelMap.set(fullKey, ch.number); - } - - // Merge overlapping channels (within new sets only) - const { mergedChannels, channelMapping } = mergeOverlappingChannels(channelSets, nextChannelNumber); - - // Update mapping to use existing channels where ALL settings match - const finalChannelMapping = new Map(); - const channelsToAdd: Channel[] = []; - - for (const newChannel of mergedChannels) { - const fullKey = getChannelFullKey(newChannel); - - if (existingChannelMap.has(fullKey)) { - // This exact channel already exists - use existing channel - const existingChannelNum = existingChannelMap.get(fullKey)!; - - // Update all mappings that point to this merged channel - for (const [origNum, mergedNum] of channelMapping.entries()) { - if (mergedNum === newChannel.number) { - finalChannelMapping.set(origNum, existingChannelNum); - } - } - } else { - // New unique channel (or different settings) - add it - channelsToAdd.push(newChannel); - - // Copy mapping as-is for this channel - for (const [origNum, mergedNum] of channelMapping.entries()) { - if (mergedNum === newChannel.number) { - finalChannelMapping.set(origNum, newChannel.number); - } - } - } - } + // Merge overlaps within the new sets and dedupe against existing channels + // (a new channel is reused only when ALL settings match an existing one). + const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting( + channels, + channelSets, + nextChannelNumber + ); // Create zones with final channel numbers const newZones: Zone[] = []; @@ -103,7 +74,7 @@ export const FixedChannelsSource: React.FC = ({ // Map original channel numbers to final channel numbers const zoneChannelNumbers = setChannels - .map(ch => finalChannelMapping.get(ch.number)) + .map(ch => channelMapping.get(ch.number)) .filter((num): num is number => num !== undefined) .sort((a, b) => a - b); diff --git a/src/components/import/sources/RptrsSource.tsx b/src/components/import/sources/RptrsSource.tsx index 87982d9..8d61136 100644 --- a/src/components/import/sources/RptrsSource.tsx +++ b/src/components/import/sources/RptrsSource.tsx @@ -3,7 +3,7 @@ import { formatPlural } from '../../../utils/formatPlural'; import { useImportStores } from '../../../hooks/useImportStores'; import { getNextChannelNumber, selectionCardClass } from '../../../utils/importHelpers'; import { generateRptrsChannels } from '../../../services/rptrsChannels'; -import { mergeOverlappingChannels } from '../../../services/channelMerger'; +import { mergeChannelSetsWithExisting } from '../../../services/channelMerger'; import { convertRptrFrequency, type RptrData } from '../../../data/rptrsData'; import { SelectAllButtons } from '../SelectAllButtons'; import { Button } from '../../ui/Button'; @@ -88,17 +88,33 @@ export const RptrsSource: React.FC = ({ return; } - // Merge with existing channels to avoid duplicates - const mergedResult = mergeOverlappingChannels([channels, result.channels]); - setChannels(mergedResult.mergedChannels); + // Merge overlaps within the new channels and dedupe against existing ones. + // Existing channels are never renumbered — renumbering them would break + // every zone and scan list that references them. + const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting( + channels, + [result.channels], + nextChannelNumber + ); + setChannels([...channels, ...channelsToAdd]); - // Add zones - const updatedZones = [...zones, ...result.zones]; - setZones(updatedZones); + // Remap the generated zones through the merge mapping: a new channel that + // collapsed into another (or matched an existing channel) changed number. + const remappedZones = result.zones + .map(zone => ({ + ...zone, + channels: [...new Set( + zone.channels + .map(num => channelMapping.get(num)) + .filter((num): num is number => num !== undefined) + )].sort((a, b) => a - b), + })) + .filter(zone => zone.channels.length > 0); + setZones([...zones, ...remappedZones]); onGenerationResult({ - channels: result.channels.length, - zones: result.zones.length, + channels: channelsToAdd.length, + zones: remappedZones.length, }); // Clear selection diff --git a/src/services/channelMerger.ts b/src/services/channelMerger.ts index f3883fe..a7ea7e9 100644 --- a/src/services/channelMerger.ts +++ b/src/services/channelMerger.ts @@ -101,6 +101,56 @@ export function mergeOverlappingChannels( return { mergedChannels, channelMapping }; } +/** + * Merge new channel sets against each other AND against the existing channel list. + * + * - Overlapping frequencies WITHIN the new sets are merged (combined name, higher power). + * - A new channel whose full key (frequency + name + mode + bandwidth + power + tones) + * matches an existing channel is dropped and mapped to the existing channel's number. + * - Existing channels are never renumbered, merged, or modified. + * + * IMPORTANT: channel numbers must be unique ACROSS all newChannelSets — the returned + * mapping is keyed by them. Number each set with a distinct range before calling + * (e.g. set 1 → 1..N, set 2 → N+1..M). + * + * Returns the channels to append and a mapping from each new channel's original + * number to its final number, for remapping zone/scan-list references. + */ +export function mergeChannelSetsWithExisting( + existingChannels: Channel[], + newChannelSets: Channel[][], + startChannelNumber: number +): { + channelsToAdd: Channel[]; + channelMapping: Map; // original number -> final number +} { + const existingChannelMap = new Map(); // full key -> channel number + for (const ch of existingChannels) { + existingChannelMap.set(getChannelFullKey(ch), ch.number); + } + + const { mergedChannels, channelMapping } = mergeOverlappingChannels(newChannelSets, startChannelNumber); + + const finalChannelMapping = new Map(); + const channelsToAdd: Channel[] = []; + + for (const newChannel of mergedChannels) { + const fullKey = getChannelFullKey(newChannel); + // An exact match reuses the existing channel; otherwise the channel is added. + const finalNumber = existingChannelMap.get(fullKey) ?? newChannel.number; + if (finalNumber === newChannel.number) { + channelsToAdd.push(newChannel); + } + for (const [origNum, mergedNum] of channelMapping.entries()) { + if (mergedNum === newChannel.number) { + finalChannelMapping.set(origNum, finalNumber); + } + } + } + + return { channelsToAdd, channelMapping: finalChannelMapping }; +} + /** * Get a frequency key for a channel (for comparison) */ diff --git a/tests/unit/channelMerger.test.ts b/tests/unit/channelMerger.test.ts index 41049c8..6539b2d 100644 --- a/tests/unit/channelMerger.test.ts +++ b/tests/unit/channelMerger.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { mergeOverlappingChannels, getChannelFrequencyKey, getChannelFullKey } from '../../src/services/channelMerger'; +import { mergeOverlappingChannels, mergeChannelSetsWithExisting, getChannelFrequencyKey, getChannelFullKey } from '../../src/services/channelMerger'; import { createDefaultChannel } from '../../src/utils/channelHelpers'; function ch(number: number, rx: number, tx: number, name = `CH${number}`) { @@ -73,3 +73,58 @@ describe('getChannelFullKey', () => { expect(key).toContain('TestCh'); }); }); + +describe('mergeChannelSetsWithExisting', () => { + it('keeps per-set mappings intact when sets use distinct number ranges (zone-scramble regression)', () => { + // Two sets, distinct temp ranges (as FixedChannelsSource now numbers them). + const setA = [ch(1, 146.52, 146.52, 'A1'), ch(2, 147.0, 147.0, 'A2')]; + const setB = [ch(3, 462.5625, 462.5625, 'B1'), ch(4, 462.5875, 462.5875, 'B2')]; + + const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting([], [setA, setB], 10); + + // Set A's zone lookups must resolve to set A's channels, not set B's. + expect(setA.map(c => channelMapping.get(c.number))).toEqual([10, 11]); + expect(setB.map(c => channelMapping.get(c.number))).toEqual([12, 13]); + expect(channelsToAdd).toHaveLength(4); + }); + + it('maps a full-key match to the existing channel instead of adding it', () => { + const existing = [ch(7, 146.52, 146.52, 'SAME')]; + const incoming = [ch(1, 146.52, 146.52, 'SAME')]; + + const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting(existing, [incoming], 100); + + expect(channelsToAdd).toHaveLength(0); + expect(channelMapping.get(1)).toBe(7); + }); + + it('adds a channel that shares frequency but differs in settings from an existing one', () => { + const existing = [ch(7, 146.52, 146.52, 'OLD NAME')]; + const incoming = [ch(1, 146.52, 146.52, 'NEW NAME')]; + + const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting(existing, [incoming], 100); + + expect(channelsToAdd).toHaveLength(1); + expect(channelMapping.get(1)).toBe(100); + }); + + it('collapses same-frequency channels across new sets and maps both to one final number', () => { + const setA = [ch(1, 462.5625, 462.5625, 'FRS 1')]; + const setB = [ch(2, 462.5625, 462.5625, 'GMRS 1')]; + + const { channelsToAdd, channelMapping } = mergeChannelSetsWithExisting([], [setA, setB], 50); + + expect(channelsToAdd).toHaveLength(1); + expect(channelMapping.get(1)).toBe(50); + expect(channelMapping.get(2)).toBe(50); + }); + + it('never touches existing channels', () => { + const existing = [ch(3, 146.52, 146.52, 'KEEP'), ch(9, 147.0, 147.0, 'KEEP2')]; + const before = JSON.stringify(existing); + + mergeChannelSetsWithExisting(existing, [[ch(1, 450.0, 450.0, 'NEW')]], 10); + + expect(JSON.stringify(existing)).toBe(before); + }); +}); From a25141ebf23ee47bc3d60ac95c224d8094a0921e Mon Sep 17 00:00:00 2001 From: Alex Harvey Date: Wed, 8 Jul 2026 01:17:30 -0700 Subject: [PATCH 6/6] Fix of some dup code into component --- src/components/rxgroups/RXGroupsList.tsx | 206 +++----------------- src/components/scanlists/ScanListsList.tsx | 188 ++----------------- src/components/ui/OrderedItemPicker.tsx | 208 +++++++++++++++++++++ src/components/ui/pickerItems.ts | 28 +++ src/components/zones/ZonesList.tsx | 188 ++----------------- 5 files changed, 303 insertions(+), 515 deletions(-) create mode 100644 src/components/ui/OrderedItemPicker.tsx create mode 100644 src/components/ui/pickerItems.ts diff --git a/src/components/rxgroups/RXGroupsList.tsx b/src/components/rxgroups/RXGroupsList.tsx index c331954..cc1019b 100644 --- a/src/components/rxgroups/RXGroupsList.tsx +++ b/src/components/rxgroups/RXGroupsList.tsx @@ -5,6 +5,8 @@ import { useRXGroupsStore } from '../../store/rxGroupsStore'; import { useQuickContactsStore } from '../../store/quickContactsStore'; import type { RXGroup } from '../../models/RXGroup'; import { ListDetailLayout } from '../ui/ListDetailLayout'; +import { OrderedItemPicker } from '../ui/OrderedItemPicker'; +import type { PickerItem } from '../ui/pickerItems'; import { Card } from '../ui/Card'; import { EmptyState } from '../ui/EmptyState'; import { ConfirmModal } from '../ui/ConfirmModal'; @@ -240,77 +242,21 @@ interface RXGroupEditorProps { const RXGroupEditor: React.FC = ({ group, onAlert }) => { const { updateGroup } = useRXGroupsStore(); const { contacts: talkGroups } = useQuickContactsStore(); - const [searchQuery, setSearchQuery] = useState(''); - const handleAddTalkGroup = (talkGroupIndex: number) => { - // Find the talk group by index to get its DMR ID (contactNumber) - const talkGroup = talkGroups.find(tg => tg.index === talkGroupIndex); - if (!talkGroup) return; - - if (group.talkGroupIndices.length >= 32) { - onAlert('Maximum of 32 talk groups per RX group allowed.'); - return; - } - - const dmrId = talkGroup.contactNumber; - if (!group.talkGroupIndices.includes(dmrId)) { - updateGroup(group.index, { - talkGroupIndices: [...group.talkGroupIndices, dmrId].sort((a, b) => a - b), - }); - } - }; - - const handleRemoveTalkGroup = (talkGroupIndex: number) => { - // Find the talk group by index to get its DMR ID (contactNumber) - const talkGroup = talkGroups.find(tg => tg.index === talkGroupIndex); - if (!talkGroup) return; - - const dmrId = talkGroup.contactNumber; - updateGroup(group.index, { - talkGroupIndices: group.talkGroupIndices.filter(id => id !== dmrId), - }); - }; - - const handleReorderTalkGroup = (fromIndex: number, toIndex: number) => { - // fromIndex/toIndex are array indices in groupTalkGroups array - const newPositions = [...group.talkGroupIndices]; - const [removed] = newPositions.splice(fromIndex, 1); - newPositions.splice(toIndex, 0, removed); - updateGroup(group.index, { talkGroupIndices: newPositions }); - }; - - const availableTalkGroups = talkGroups - .filter(tg => { - return !group.talkGroupIndices.includes(tg.contactNumber) && - tg.callType === 0x04; // Only Group Call (exclude Private Call 0x03 and All Call 0x05) - }) - .map(tg => tg.index) - .sort((a, b) => a - b); + // group.talkGroupIndices stores DMR IDs (contactNumber); rows display "index: name". + const talkGroupItem = (tg: (typeof talkGroups)[number]): PickerItem => ({ + id: tg.contactNumber, + label: `${tg.index}: ${tg.name}`, + searchText: `${tg.index} ${tg.name} ${tg.contactNumber}`, + }); - const filteredAvailableTalkGroups = searchQuery.trim() - ? availableTalkGroups.filter((tgIndex) => { - const talkGroup = talkGroups.find(tg => tg.index === tgIndex); - if (!talkGroup) return false; - - const query = searchQuery.toLowerCase().trim(); - - // Search in name - if (talkGroup.name.toLowerCase().includes(query)) return true; - - // Search in index - if (talkGroup.index.toString().includes(query)) return true; - - // Search in contact number - if (talkGroup.contactNumber?.toString().includes(query)) return true; - - return false; - }) - : availableTalkGroups; - - // Find talk groups by matching DMR ID (contactNumber) - const groupTalkGroups = group.talkGroupIndices - .map(dmrId => talkGroups.find(tg => tg.contactNumber === dmrId)) - .filter(tg => tg !== undefined); + const availableItems = talkGroups + .filter(tg => + !group.talkGroupIndices.includes(tg.contactNumber) && + tg.callType === 0x04 // Only Group Call (exclude Private Call 0x03 and All Call 0x05) + ) + .sort((a, b) => a.index - b.index) + .map(talkGroupItem); return (
@@ -325,114 +271,20 @@ const RXGroupEditor: React.FC = ({ group, onAlert }) => { placeholder="Enter group name" />
-
-

- Talk Groups in RX Group ({group.talkGroupIndices.length}/32) -

- {group.talkGroupIndices.length === 0 ? ( -

No talk groups in this RX group

- ) : ( -
- {groupTalkGroups.map((talkGroup, index) => ( -
-
- {index + 1}. - - {talkGroup!.index}: {talkGroup!.name} - -
-
- {index > 0 && ( - - )} - {index < groupTalkGroups.length - 1 && ( - - )} - -
-
- ))} -
- )} -
- -
-

- Available Talk Groups ({filteredAvailableTalkGroups.length} of {availableTalkGroups.length}) -

- {availableTalkGroups.length === 0 ? ( -

All talk groups are in this RX group

- ) : group.talkGroupIndices.length >= 32 ? ( -

RX group is full (32 talk groups maximum)

- ) : ( - <> -
-
- setSearchQuery(e.target.value)} - placeholder="Search talk groups..." - className="w-full bg-transparent border border-neon-cyan border-opacity-30 rounded px-3 py-1.5 pl-9 text-white text-xs focus:outline-none focus:border-neon-cyan focus:shadow-glow-cyan" - /> - - 🔍 - - {searchQuery && ( - - )} -
-
- {filteredAvailableTalkGroups.length === 0 ? ( -

No talk groups match your search

- ) : ( -
- {filteredAvailableTalkGroups.map((tgIndex) => { - const talkGroup = talkGroups.find(tg => tg.index === tgIndex); - return ( - - ); - })} -
- )} - - )} -
+ { + const tg = talkGroups.find(t => t.contactNumber === dmrId); + return tg ? talkGroupItem(tg) : undefined; + }} + onChange={(ids) => updateGroup(group.index, { talkGroupIndices: ids })} + maxItems={32} + itemNoun="talk group" + containerNoun="RX group" + onAlert={onAlert} + padded={false} + /> ); }; diff --git a/src/components/scanlists/ScanListsList.tsx b/src/components/scanlists/ScanListsList.tsx index 6aac457..9f4f42f 100644 --- a/src/components/scanlists/ScanListsList.tsx +++ b/src/components/scanlists/ScanListsList.tsx @@ -7,6 +7,8 @@ import { useChannelsStore } from '../../store/channelsStore'; import type { ScanList } from '../../models/ScanList'; import type { Channel } from '../../models/Channel'; import { ListDetailLayout } from '../ui/ListDetailLayout'; +import { OrderedItemPicker } from '../ui/OrderedItemPicker'; +import { channelPickerItem } from '../ui/pickerItems'; import { Card } from '../ui/Card'; import { SectionTitle } from '../ui/SectionTitle'; import { EmptyState } from '../ui/EmptyState'; @@ -402,73 +404,12 @@ interface ScanListEditorProps { const ScanListEditor: React.FC = ({ scanList, onAlert }) => { const { updateScanList } = useScanListsStore(); const { channels } = useChannelsStore(); - const [searchQuery, setSearchQuery] = useState(''); const [showSettings, setShowSettings] = useState(true); - const handleAddChannel = (channelNumber: number) => { - if (scanList.channels.length >= 15) { - onAlert('Maximum of 15 channels per scan list allowed.'); - return; - } - if (!scanList.channels.includes(channelNumber)) { - updateScanList(scanList.name, { - channels: [...scanList.channels, channelNumber].sort((a, b) => a - b), - }); - } - }; - - const handleRemoveChannel = (channelNumber: number) => { - updateScanList(scanList.name, { - channels: scanList.channels.filter(ch => ch !== channelNumber), - }); - }; - - const handleReorderChannel = (fromIndex: number, toIndex: number) => { - const newChannels = [...scanList.channels]; - const [removed] = newChannels.splice(fromIndex, 1); - newChannels.splice(toIndex, 0, removed); - updateScanList(scanList.name, { channels: newChannels }); - }; - - const availableChannels = channels + const availableItems = channels .filter(ch => !scanList.channels.includes(ch.number)) - .map(ch => ch.number) - .sort((a, b) => a - b); - - const filteredAvailableChannels = searchQuery.trim() - ? availableChannels.filter((chNum) => { - const channel = channels.find(ch => ch.number === chNum); - if (!channel) return false; - - const query = searchQuery.toLowerCase().trim(); - - // Search in name - if (channel.name.toLowerCase().includes(query)) return true; - - // Search in channel number - if (channel.number.toString().includes(query)) return true; - - // Search in frequencies - const rxFreq = channel.rxFrequency.toFixed(4); - const txFreq = channel.txFrequency.toFixed(4); - if (rxFreq.includes(query) || txFreq.includes(query)) return true; - - // Search in mode - if (channel.mode.toLowerCase().includes(query)) return true; - - // Search in bandwidth - if (channel.bandwidth.toLowerCase().includes(query)) return true; - - // Search in power - if (channel.power.toLowerCase().includes(query)) return true; - - return false; - }) - : availableChannels; - - const scanListChannels = scanList.channels - .map(chNum => channels.find(ch => ch.number === chNum)) - .filter(ch => ch !== undefined); + .sort((a, b) => a.number - b.number) + .map(channelPickerItem); // Get sorted list of channels for dropdowns const sortedChannels = [...channels].sort((a, b) => a.number - b.number); @@ -609,111 +550,20 @@ const ScanListEditor: React.FC = ({ scanList, onAlert }) => {/* Channels Section */} -
-

- Channels in Scan List ({scanList.channels.length}/15) -

- {scanList.channels.length === 0 ? ( -

No channels in this scan list

- ) : ( -
- {scanListChannels.map((channel, index) => ( -
-
- {index + 1}. - - {channel!.number}: {channel!.name} - -
-
- {index > 0 && ( - - )} - {index < scanListChannels.length - 1 && ( - - )} - -
-
- ))} -
- )} -
- -
-

- Available Channels ({filteredAvailableChannels.length} of {availableChannels.length}) -

- {availableChannels.length === 0 ? ( -

All channels are in this scan list

- ) : scanList.channels.length >= 15 ? ( -

Scan list is full (15 channels maximum)

- ) : ( - <> -
-
- setSearchQuery(e.target.value)} - placeholder="Search channels..." - className="w-full bg-transparent border border-neon-cyan border-opacity-30 rounded px-3 py-1.5 pl-9 text-white text-xs focus:outline-none focus:border-neon-cyan focus:shadow-glow-cyan" - /> - - 🔍 - - {searchQuery && ( - - )} -
-
- {filteredAvailableChannels.length === 0 ? ( -

No channels match your search

- ) : ( -
- {filteredAvailableChannels.map((chNum) => { - const channel = channels.find(ch => ch.number === chNum); - return ( - - ); - })} -
- )} - - )} -
+ { + const ch = channels.find(c => c.number === num); + return ch ? channelPickerItem(ch) : undefined; + }} + onChange={(ids) => updateScanList(scanList.name, { channels: ids })} + maxItems={15} + itemNoun="channel" + containerNoun="scan list" + onAlert={onAlert} + padded={false} + /> ); }; diff --git a/src/components/ui/OrderedItemPicker.tsx b/src/components/ui/OrderedItemPicker.tsx new file mode 100644 index 0000000..8842049 --- /dev/null +++ b/src/components/ui/OrderedItemPicker.tsx @@ -0,0 +1,208 @@ +import React, { useState } from 'react'; +import { formatPlural } from '../../utils/formatPlural'; +import type { PickerItem } from './pickerItems'; + +/** + * OrderedItemPicker — the shared "ordered selected list + searchable available + * list" editor used by zones, scan lists, and RX groups. + * + * Ordering semantics (the reason this component exists — keep them): + * - Add APPENDS to the end. The list order is what gets written to the radio + * (knob/scan order), so adding must never re-sort a manually arranged list. + * - Reorder/remove/add all operate on the RESOLVED list and write back its ids. + * A stored id that no longer resolves (stale reference from an import or an + * old codeplug) is therefore dropped on the first edit, and reorder indices + * always match the rows on screen. + */ + +interface OrderedItemPickerProps { + /** Ordered ids as stored; may contain ids that no longer resolve */ + selectedIds: number[]; + /** Addable items, in display order (exclude already-selected + domain filtering) */ + availableItems: PickerItem[]; + /** Resolve a stored id for display; return undefined for stale references */ + resolveItem: (id: number) => PickerItem | undefined; + onChange: (ids: number[]) => void; + maxItems: number; + /** e.g. 'channel', 'talk group' */ + itemNoun: string; + /** e.g. 'zone', 'scan list', 'RX group' */ + containerNoun: string; + onAlert: (message: string) => void; + /** Stretch to fill the parent column (zone editor layout) */ + fillHeight?: boolean; + /** Disable the root padding when nested in an already-padded container */ + padded?: boolean; +} + +export const OrderedItemPicker: React.FC = ({ + selectedIds, + availableItems, + resolveItem, + onChange, + maxItems, + itemNoun, + containerNoun, + onAlert, + fillHeight = false, + padded = true, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + + const itemNounPlural = formatPlural(2, itemNoun); + const resolved = selectedIds + .map(id => resolveItem(id)) + .filter((item): item is PickerItem => item !== undefined); + const staleCount = selectedIds.length - resolved.length; + const resolvedIds = resolved.map(item => item.id); + + const handleAdd = (id: number) => { + if (selectedIds.length >= maxItems) { + onAlert(`Maximum of ${maxItems} ${itemNounPlural} per ${containerNoun} allowed.`); + return; + } + if (!resolvedIds.includes(id)) { + onChange([...resolvedIds, id]); + } + }; + + const handleRemove = (id: number) => { + onChange(resolvedIds.filter(existing => existing !== id)); + }; + + const handleReorder = (fromIndex: number, toIndex: number) => { + const next = [...resolvedIds]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + onChange(next); + }; + + const query = searchQuery.toLowerCase().trim(); + const filteredAvailable = query + ? availableItems.filter( + item => + item.label.toLowerCase().includes(query) || + item.searchText?.toLowerCase().includes(query) + ) + : availableItems; + + return ( +
+
+

+ {`${itemNounPlural.charAt(0).toUpperCase()}${itemNounPlural.slice(1)}`} in{' '} + {containerNoun} ({selectedIds.length}/{maxItems}) +

+ {staleCount > 0 && ( +

+ {staleCount} {formatPlural(staleCount, 'entry', 'entries')} no longer{' '} + {staleCount === 1 ? 'resolves' : 'resolve'} and will be removed on the next edit. +

+ )} + {selectedIds.length === 0 ? ( +

No {itemNounPlural} in this {containerNoun}

+ ) : ( +
+ {resolved.map((item, index) => ( +
+
+ {index + 1}. + {item.label} +
+
+ {index > 0 && ( + + )} + {index < resolved.length - 1 && ( + + )} + +
+
+ ))} +
+ )} +
+ +
+

+ Available {`${itemNounPlural.charAt(0).toUpperCase()}${itemNounPlural.slice(1)}`} ( + {filteredAvailable.length} of {availableItems.length}) +

+ {availableItems.length === 0 ? ( +

All {itemNounPlural} are in this {containerNoun}

+ ) : selectedIds.length >= maxItems ? ( +

+ {`${containerNoun.charAt(0).toUpperCase()}${containerNoun.slice(1)}`} is full ({maxItems}{' '} + {itemNounPlural} maximum) +

+ ) : ( + <> +
+
+ setSearchQuery(e.target.value)} + placeholder={`Search ${itemNounPlural}...`} + className="w-full bg-transparent border border-neon-cyan border-opacity-30 rounded px-3 py-1.5 pl-9 text-white text-xs focus:outline-none focus:border-neon-cyan focus:shadow-glow-cyan" + /> + + 🔍 + + {searchQuery && ( + + )} +
+
+ {filteredAvailable.length === 0 ? ( +

No {itemNounPlural} match your search

+ ) : ( +
+ {filteredAvailable.map((item) => ( + + ))} +
+ )} + + )} +
+
+ ); +}; diff --git a/src/components/ui/pickerItems.ts b/src/components/ui/pickerItems.ts new file mode 100644 index 0000000..7738b99 --- /dev/null +++ b/src/components/ui/pickerItems.ts @@ -0,0 +1,28 @@ +import type { Channel } from '../../models/Channel'; + +/** Item shape consumed by OrderedItemPicker. */ +export interface PickerItem { + /** Stable identity as stored in the parent's ordered id list */ + id: number; + /** Row/button label, e.g. "5: VE7RAG" */ + label: string; + /** Extra text matched by the search box (label is always matched) */ + searchText?: string; +} + +/** Build a PickerItem for a channel (shared by zone and scan list editors). */ +export function channelPickerItem(ch: Channel): PickerItem { + return { + id: ch.number, + label: `${ch.number}: ${ch.name}`, + searchText: [ + ch.number, + ch.name, + ch.rxFrequency.toFixed(4), + ch.txFrequency.toFixed(4), + ch.mode, + ch.bandwidth, + ch.power, + ].join(' '), + }; +} diff --git a/src/components/zones/ZonesList.tsx b/src/components/zones/ZonesList.tsx index a5b2d95..3fb5588 100644 --- a/src/components/zones/ZonesList.tsx +++ b/src/components/zones/ZonesList.tsx @@ -5,6 +5,8 @@ import { useZonesStore } from '../../store/zonesStore'; import { useChannelsStore } from '../../store/channelsStore'; import type { Zone } from '../../models/Zone'; import { ListDetailLayout } from '../ui/ListDetailLayout'; +import { OrderedItemPicker } from '../ui/OrderedItemPicker'; +import { channelPickerItem } from '../ui/pickerItems'; import { Card } from '../ui/Card'; import { SectionTitle } from '../ui/SectionTitle'; import { EmptyState } from '../ui/EmptyState'; @@ -228,178 +230,26 @@ interface ZoneEditorProps { const ZoneEditor: React.FC = ({ zone, onAlert }) => { const { updateZone } = useZonesStore(); const { channels } = useChannelsStore(); - const [searchQuery, setSearchQuery] = useState(''); - const handleAddChannel = (channelNumber: number) => { - if (zone.channels.length >= 64) { - onAlert('Maximum of 64 channels per zone allowed.'); - return; - } - if (!zone.channels.includes(channelNumber)) { - updateZone(zone.id, { - channels: [...zone.channels, channelNumber].sort((a, b) => a - b), - }); - } - }; - - const handleRemoveChannel = (channelNumber: number) => { - updateZone(zone.id, { - channels: zone.channels.filter(ch => ch !== channelNumber), - }); - }; - - const handleReorderChannel = (fromIndex: number, toIndex: number) => { - const newChannels = [...zone.channels]; - const [removed] = newChannels.splice(fromIndex, 1); - newChannels.splice(toIndex, 0, removed); - updateZone(zone.id, { channels: newChannels }); - }; - - const availableChannels = channels + const availableItems = channels .filter(ch => !zone.channels.includes(ch.number)) - .map(ch => ch.number) - .sort((a, b) => a - b); - - const filteredAvailableChannels = searchQuery.trim() - ? availableChannels.filter((chNum) => { - const channel = channels.find(ch => ch.number === chNum); - if (!channel) return false; - - const query = searchQuery.toLowerCase().trim(); - - // Search in name - if (channel.name.toLowerCase().includes(query)) return true; - - // Search in channel number - if (channel.number.toString().includes(query)) return true; - - // Search in frequencies - const rxFreq = channel.rxFrequency.toFixed(4); - const txFreq = channel.txFrequency.toFixed(4); - if (rxFreq.includes(query) || txFreq.includes(query)) return true; - - // Search in mode - if (channel.mode.toLowerCase().includes(query)) return true; - - // Search in bandwidth - if (channel.bandwidth.toLowerCase().includes(query)) return true; - - // Search in power - if (channel.power.toLowerCase().includes(query)) return true; - - return false; - }) - : availableChannels; - - const zoneChannels = zone.channels - .map(chNum => channels.find(ch => ch.number === chNum)) - .filter(ch => ch !== undefined); + .sort((a, b) => a.number - b.number) + .map(channelPickerItem); return ( -
-
-

Channels in Zone ({zone.channels.length}/64)

- {zone.channels.length === 0 ? ( -

No channels in this zone

- ) : ( -
- {zoneChannels.map((channel, index) => ( -
-
- {index + 1}. - - {channel!.number}: {channel!.name} - -
-
- {index > 0 && ( - - )} - {index < zoneChannels.length - 1 && ( - - )} - -
-
- ))} -
- )} -
- -
-

- Available Channels ({filteredAvailableChannels.length} of {availableChannels.length}) -

- {availableChannels.length === 0 ? ( -

All channels are in this zone

- ) : zone.channels.length >= 64 ? ( -

Zone is full (64 channels maximum)

- ) : ( - <> -
-
- setSearchQuery(e.target.value)} - placeholder="Search channels..." - className="w-full bg-transparent border border-neon-cyan border-opacity-30 rounded px-3 py-1.5 pl-9 text-white text-xs focus:outline-none focus:border-neon-cyan focus:shadow-glow-cyan" - /> - - 🔍 - - {searchQuery && ( - - )} -
-
- {filteredAvailableChannels.length === 0 ? ( -

No channels match your search

- ) : ( -
- {filteredAvailableChannels.map((chNum) => { - const channel = channels.find(ch => ch.number === chNum); - return ( - - ); - })} -
- )} - - )} -
-
+ { + const ch = channels.find(c => c.number === num); + return ch ? channelPickerItem(ch) : undefined; + }} + onChange={(ids) => updateZone(zone.id, { channels: ids })} + maxItems={64} + itemNoun="channel" + containerNoun="zone" + onAlert={onAlert} + fillHeight + /> ); };