From 69e9ed0421dbbd1df593d8b1df663d49efe3fb27 Mon Sep 17 00:00:00 2001 From: Igor Nogueira de Oliveira Date: Mon, 6 Jul 2026 19:42:53 -0300 Subject: [PATCH 1/6] Firmware 4.01a: digital-modes profile, AGC level control, ADIF self-QSO fix - Fix LCD S-meter freezing when CAT activates (smode was forced to 0 permanently). - Remove VOX+noise gate, RIT, XO, CW/AM/FM TX, VFO B/Split, Fast AGC (flash 97%->86%). - Block TX outside LSB/USB in switch_rxtx (PTT and CAT alike); AM/FM/CW stay RX-only. - Single M0PUB AGC as OFF/ON plus new AL CAT command and AGC level menu (1-14, default 4). - Replace all float math with integer (dBm S-meter +/-1dB, SI5351 divider, Q15 AM DC-blocker). - Rename menu labels to fit the 16x2 LCD; PA bias max default 130 (was 160). - Fix ADIF export logging the operator as their own QSO partner (inverted tx/rx roles in segmentIsConfirmed). - Web: AGC level stepper, 12-command poll batch, mock+protocol tests updated (464 passing). - Hardware test bed: 46 RX-only checks incl. mode clamps and AL; fix A2 frames dropped by the noise filter. - Add firmware README documenting the full lineage vs stock QCX-SSB R1.02w. --- README.md | 25 ++- firmware/usdxBLACKBRICK/README.md | 102 +++++++++ firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino | 234 ++++++++++----------- scripts/cat-hardware-test.ts | 142 ++++++++++--- src/components/RadioCATPanel.tsx | 35 +-- src/hooks/useRadioCAT.ts | 49 +++-- src/lib/cat/__tests__/protocol.test.ts | 50 +++-- src/lib/cat/mockSerial.ts | 26 ++- src/lib/ft/__tests__/parser.test.ts | 39 ++++ src/lib/ft/parser.ts | 28 ++- 10 files changed, 517 insertions(+), 213 deletions(-) create mode 100644 firmware/usdxBLACKBRICK/README.md diff --git a/README.md b/README.md index fa71073..0c288d9 100644 --- a/README.md +++ b/README.md @@ -471,20 +471,31 @@ If the port is busy or silent, make sure no other CAT application (hamlib, rigct ### Custom CAT commands (PU7FTW extensions) -All commands terminate with `;`. All SET commands echo the new value as a GET reply. Commands are safe to batch in a single write — the firmware processes them in order and concatenates replies, e.g. `FA;MD;AG0;FW;VO;AT;A2;NR;BL;` returns all 9 replies in one read window. +All commands terminate with `;`. All SET commands echo the effective value as a GET reply — if a SET is rejected (out of range), the echo returns the **old** value, which the web app relies on. Commands are safe to batch in a single write — the firmware processes them in order and concatenates replies, e.g. `FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;` (the app's poll batch) returns all 12 replies in one read window. | Command | Query | Set | Range | Notes | |---------|-------|-----|-------|-------| | **VO** — volume | `VO;` → `VOn;` | `VOn;` | −1…16 | −1 = mute | -| **AT** — ATT1 | `AT;` → `ATn;` | `ATn;` | 0…7 | dB steps: 0/−13/−20/−33/−40/−53/−60/−73 dB | -| **A2** — ATT2 | `A2;` → `A2n;` | `A2n;` | 0…16 | linear index | +| **AT** — analog attenuator | `AT;` → `ATn;` | `ATn;` | 0…7 | dB steps: 0/−13/−20/−33/−40/−53/−60/−73 dB | +| **A2** — digital attenuator | `A2;` → `A2n;` | `A2n;` | 0…16 | −6.02 dB per step | | **NR** — noise reduction | `NR;` → `NRn;` | `NRn;` | 0…8 | 0 = off | | **BL** — backlight | `BL;` → `BL0;`/`BL1;` | `BL0;`/`BL1;` | 0…1 | | -| **AG0** — AGC | `AG0;` → `AG0n;` | `AG0n;` | 0…2 | 0=OFF 1=Fast 2=Slow | +| **AG0** — AGC on/off | `AG0;` → `AG0n;` | `AG0n;` | 0…1 | single algorithm (M0PUB fast-attack/slow-decay); values >1 rejected | +| **AL** — AGC level | `AL;` → `ALn;` | `ALn;` | 1…14 | target window: output peaks held in [n·256 … n·384]; default 4 | | **FW** — filter BW | `FW;` → `FWn;` | `FWn;` | 0…7 | 0=Full 1=3k 2=2.4k 3=1.8k 4=500 5=200 6=100 7=50 Hz | +| **SM** — S-meter | `SM;` → `SMn;` | — | dBm (signed) | read-only; integer-math dBm, ±1 dB vs the old float version | +| **DR** — TX drive | `DR;` → `DRn;` | `DRn;` | 0…8 | pre-clipping input gain (each step doubles the envelope); 8 = always max | +| **PM** — PA bias min | `PM;` → `PMn;` | `PMn;` | 0…max−1 | PWM LUT idle endpoint; SET rebuilds the LUT live | +| **PX** — PA bias max | `PX;` → `PXn;` | `PXn;` | min+1…255 | PWM LUT full-drive endpoint — the real output-power control for digital modes | +| **XF** — ref frequency | `XF;` → `XFnnnnnnnn;` | `XFnnnnnnnn;` | 14–28 MHz | si5351 fxtal calibration; used by the calibration wizard | +| **FD** — factory defaults | `FD;` → one CSV frame | — | 11 values | `FD,,,,,,,,,,;` | +| **SR** — soft restart | `SR;` → `SR1;` | — | | acks then watchdog-reboots (≈2–3 s off the wire) | +| **SR2** — factory reset | `SR2;` → `SR2;` | — | | wipes ALL settings incl. band data and calibration, then reboots | | **TQ** — PTT state | `TQ;` → `TQ0;`/`TQ1;` | `TQ0;`/`TQ1;` | 0…1 | firmware also broadcasts `TQ0;`/`TQ1;` unsolicited on PTT transitions | -Standard TS-480 commands also supported: `FA` (VFO A freq get/set), `MD` (mode), `IF` (37-byte info frame), `TX`/`RX` (PTT), `ID`, `PS`, `AI`, `VX`. +Standard TS-480 commands also supported: `FA` (frequency get/set — single VFO), `MD` (mode; digits 4/5 = FM/AM are selectable for **RX only**, out-of-range digits clamp to LSB), `IF` (status frame), `TX`/`RX` (PTT — **ignored unless mode is LSB/USB**; AM/FM/CW are receive-only in this build), `ID`, `PS`, `AI`, `VX`. + +> **Removed vs. earlier 4.00x builds (2026-07-06):** VOX + noise gate, RIT (`RTS` offset command), TX offset (`XO`), VFO B/Split, CW/AM/FM transmit, the Fast AGC variant (replaced by `AL` level control), and all float math (integer dBm S-meter). See [`firmware/usdxBLACKBRICK/README.md`](firmware/usdxBLACKBRICK/README.md) for the complete lineage vs. the original QCX-SSB R1.02w firmware. ## Testing @@ -509,7 +520,7 @@ npm run test:coverage - TS-480 command construction (`FA`, `MD`) - Response parsing helpers (`parseFrequency`, `parseMode`, `parseIntField`, `parseBoolField`) -- All 8 custom BLACK_BRICK commands (`VO`, `AT`, `A2`, `NR`, `BL`, `AG0`, `FW`, `TQ`) +- All custom BLACK_BRICK commands (`VO`, `AT`, `A2`, `NR`, `BL`, `AG0`, `AL`, `FW`, `SM`, `DR`, `PM`, `PX`, `XF`, `FD`, `SR`, `TQ`) - Multi-command batch response splitting and 2-char prefix lookup map - IF frame parsing against the exact frame layout emitted by the firmware - Range validation for every writable parameter @@ -518,8 +529,6 @@ npm run test:coverage npm test -- src/lib/cat/__tests__/protocol.test.ts ``` -Expected result: **35 tests, 35 passed**. - ### Testbed setup (live radio required) The CAT unit tests above need no hardware. If you want to run manual smoke tests or extend the suite with hardware-in-the-loop tests: diff --git a/firmware/usdxBLACKBRICK/README.md b/firmware/usdxBLACKBRICK/README.md new file mode 100644 index 0000000..c991186 --- /dev/null +++ b/firmware/usdxBLACKBRICK/README.md @@ -0,0 +1,102 @@ +# uSDX BLACK_BRICK firmware — lineage and changes + +Customized firmware for the Chinese "black brick" uSDX clone (ATmega328P @ 20 MHz, HD44780 16×2 LCD, class-E PA, SI5351 @ 25 MHz TCXO), operated as a **CAT-controlled digital-modes transceiver**: RX with basic signal controls, TX always SSB fed by external audio (the Signal-Decoder web app or any other encoder) into the mic input, PTT via CAT. It deliberately does **no** decoding of its own. + +## Lineage + +``` +threeme3/QCX-SSB R1.02w (PE1NNZ, 2021-08-23) ← original open-source baseline + └─ GW8RDI/uSDXOpen fork (1.02wA2 … 4.00d, 2022-2023) ← multi-hardware fork + └─ this file (PU7FTW, 4.00e … 4.01a, 2026) ← BLACK_BRICK-only, digital-modes profile +``` + +This build was not forked directly from R1.02w — it descends through GW8RDI's +uSDXOpen fork — so the tables below describe the *cumulative* difference between +stock R1.02w and the current source. + +Stock R1.02w reference: [threeme3/QCX-SSB @ R1.02w](https://github.com/threeme3/QCX-SSB/blob/R1.02w/README.md) · +Fork lineage: [GW8RDI/uSDXOpen](https://github.com/GW8RDI/uSDXOpen) + +## What stock R1.02w shipped (baseline) + +Enabled by default: startup diagnostics (DIAG), CW keyer (straight/iambic-A/B + practice mode), TS-480-subset CAT, CW decoder, key-click envelope shaping, semi-QSK, RIT, VOX, CW messages with CQ auto-repeat, 8-band latching-relay LPF switching. Runtime features: LSB/USB/CW/AM/FM (RX **and** TX), VFO A/B + Split, AGC on/off, NR 0–8, analog + digital attenuators, S-meter (OFF/dBm/S/S-bar), TX drive, noise gate, PA bias min/max, CW tone 600/700 Hz, ref-freq + IQ calibration, battery-voltage indicator, CAT RX-audio streaming. Compiled out but present: FAST_AGC, SWR meter, MOX, TX delay, clock, OLED/LCD-I2C variants, multi-hardware (QCX legacy) support. + +## Added (vs. R1.02w) + +| Feature | Origin | Notes | +|---|---|---| +| Per-band freq/mode memory (KEEP_BAND_DATA) | GW8RDI 1.02wA-era | last frequency + mode restored per band | +| Directional band change, menu wrap-around, error codes | GW8RDI 1.02wA-era | | +| CAT enable menu item (no reboot needed), CAT baud menu (9600–57600) | GW8RDI 4.00a / PU7FTW | | +| CAT frequency error handling | GW8RDI 2.00d | rejects corrupted `FA` payloads (1.5–60 MHz window) | +| CAT mode change incl. AM/FM with IQ-flip tracking | GW8RDI 4.00b | since 2026-07-06 AM/FM are RX-only (see below) | +| **PU7FTW extension commands**: `VO AT A2 NR BL AG0 AL FW SM DR PM PX XF FD SR/SR2` | PU7FTW 4.00e–h | full table in the [project README](../../README.md#custom-cat-commands-pu7ftw-extensions); all SETs echo the effective value, rejects echo the old value | +| Batched multi-command CAT queries | PU7FTW | one write → concatenated replies in order; the web app polls 12 commands per round-trip | +| `SM` live S-meter over CAT | PU7FTW | dBm computed every cycle regardless of the LCD display mode | +| `PM`/`PX` PA-bias live-apply (`build_lut()` on SET) | PU7FTW | same hardware-reapply pattern as the ATT1 fix | +| `XF` ref-frequency calibration over CAT | PU7FTW | drives the web app's receive-only calibration wizard | +| `SR`/`SR2` soft restart / factory reset over CAT | PU7FTW | SR2 realigns EEPROM after param-layout changes | +| **AGC level (`AL`, menu 1.8)** | PU7FTW 2026-07-06 | target window for the M0PUB AGC, 1–14 (default 4 = the algorithm's original 1024…1536 window) | +| **TX gate: only LSB/USB can key the PA** | PU7FTW 2026-07-06 | `switch_rxtx()` refuses TX in CW/AM/FM from PTT and CAT alike — can't transmit over a station you're listening to | +| Baud-rate correction for the 20 MHz crystal | fork/PU7FTW | `Serial.begin(16000000ULL * baud / F_MCU)` | +| Backlight on correct pin (PD3) + CAT `BL` control | PU7FTW 2026-07-04 | stock/fork drove the wrong pin on this hardware | + +## Removed (vs. R1.02w) + +All removals follow the same pattern: feature flag commented out (or code deleted) with a dated note in the source. + +| Feature | Why | +|---|---| +| CW decoder, CW messages/CQ-repeat, CW keyer (iambic + practice mode) | no self-decoding; no key attached | +| **CW transmit** (dsp_tx_cw, sidetone, key-click ramp, semi-QSK, CW tone volume) | TX is always SSB from external audio; CW **RX** kept (narrow filters, `cw_offset` tuning, CW Tone menu) | +| **AM/FM transmit** (dsp_tx_am/dsp_tx_fm) | AM/FM kept as RX-only listening modes | +| **VOX + noise gate** | PTT is CAT-controlled | +| **RIT** (+ `RTS` CAT command) | the PC tunes via CAT | +| **VFO B / Split** (`FREQB`/`MODEB`, VFO menu, A/B swap) | single-VFO; CAT always tuned VFO A anyway | +| **TX offset (`XO` CAT command)** | unused by the web app and by hamlib's TS-480 driver | +| **Fast AGC variant** (`process_agc_fast`) | single algorithm now: M0PUB fast-attack/slow-decay (~60 dB range) as plain AGC ON, plus the `AL` level | +| SWR meter (fork addition), practice mode, MOX, TX delay, CAT audio streaming, QUAD, multi-hardware configs | earlier removals for flash space; BLACK_BRICK is the only supported hardware | +| Startup diagnostics (DIAG), debug/testbench, clock, VSS meter | compiled out | + +## Reimplemented / major changes (vs. R1.02w) + +| Area | Change | +|---|---| +| **S-meter dBm math** | float + `log10()` → pure integer (MSB position + 4-bit mantissa LUT, 0.376 dB steps). Validated against the float formula: 60 % identical, rest ±1 dB. Removed the only libm-log user. | +| **SI5351 divider math** | `(float)num/denom` → integer `(128*num)/denom` — the datasheet-exact floor; the float could round one step off | +| **AM demod DC blocker** | per-sample float multiply → Q15 integer (~100 cycles/sample saved in AM RX) | +| **Float support library** | fully eliminated (the three sites above were the only live float users) | +| **AGC** | dbm/peak-decay decoupled from LCD display mode (CAT `SM` stays live); `centiGain` backup/restore across TX; target window parameterized (`AL`) | +| **EEPROM layout** | param table shrank with each removal — the layout is positional, so a flash across these changes **requires `SR2;` (factory reset)** unless the version string was bumped (boot then auto-resets) | +| **Menu labels** | fitted to the 16×2 LCD budget: Volume, Noise Reduce, Analog Att, Digital Att, PA bias min/max, AGC (1.7) + AGC level (1.8) | +| ATT1 hardware apply | extracted `apply_att1()`, called from both menu and CAT SET (stock only re-applied from the menu dial) | +| Encoder boot glitch | pull-ups settle + `PCIFR` clear before enabling pin-change interrupts (stock stepped the VFO once at power-on) | + +## Known hardware quirk + +The LCD shares its data pins with the UART (PD0/PD1); every LCD write briefly +disables the serial port (`pre()`/`post()` in the source, "NOISE LEAK INTO +RX!!!"). Occasional CAT replies carry a stray trailing byte or get dropped — +the web app and the hardware test bed both tolerate/retry this by design. It +is a board-level design constraint, not a firmware bug. + +## Flash budget history (2026-07-06 session) + +| Build | Flash | Free RAM | +|---|---|---| +| 4.00h baseline | 31 338 (97 %) | 540 | +| − VOX | 30 968 | 544 | +| AGC single-algorithm + `AL` | 30 604 | 536 | +| − RIT, menu renames | 30 300 | 544 | +| − XO | 30 460→30 116 (− CW TX) | 543 | +| − VFO B/Split | 29 404 | 560 | +| − float library (integer dBm) | **28 018 (86 %)** | **560** | + +4.01a (same day) additionally sets the PA bias max default to 130 (was 160) for this unit's PA. + +## Build / flash / test + +See the [project README](../../README.md#usdx-black_brick-firmware) for the +full compile/flash/validation workflow. Every flash must pass +`npm run test:cat-hardware` (46 checks against the live radio, RX-only — TX is +never keyed by automated tests). diff --git a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino index 00206bc..3e906b0 100644 --- a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino +++ b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino @@ -50,7 +50,7 @@ Global variables use 1499 bytes (73%) of dynamic memory, leaving 549 bytes for l */ // G8RDI Modifications log: -#define VERSION "4.00h" // Fixed format "9.99z" : Additions and changes Copyright 2022-2023 GW8RDI - You can use and distribute if you maintain the copyright message, commercial use is prohibited. +#define VERSION "4.01a" // Fixed format "9.99z" : Additions and changes Copyright 2022-2023 GW8RDI - You can use and distribute if you maintain the copyright message, commercial use is prohibited. // 2022/03/04 - Added delay to show serial number at start - G8RDI mod // Added band change direction based on last freq step directions. See "case BE | DC:" - GW8RDI mod @@ -156,7 +156,7 @@ Global variables use 1499 bytes (73%) of dynamic memory, leaving 549 bytes for l #define LPF_SWITCHING_DL2MAN_USDX_REV3 1 // Default 8 band latching relays IM43 -//#define FAST_AGC 1 // Adds fast AGC option (good for CW) Slow mode not recommended. Remove for CAT if memory errors. +//#define FAST_AGC 1 // Removed 2026-07-06: instead of two AGC algorithms, this build keeps only the better M0PUB one (fast attack, slow decay, ~60dB range) as plain AGC OFF/ON, plus an "AGC level" target adjustment (menu 1.8, CAT AL command) #define CAT 1 // CAT-interface - OTHER OPTIONS, SUCH AS CW_MESSAGES and KEEP_BAND_DATA MAY TO BE DISABLED TO MAKE SPACE FOR CAT //#define CAT_EXT 1 // Extended CAT support: remote button and screen control commands over CAT @@ -165,7 +165,7 @@ Global variables use 1499 bytes (73%) of dynamic memory, leaving 549 bytes for l #ifdef CAT #define CAT_TX_CMD 1 // Send TX and RX status CAT cmds as PTT is pressed and released -#define CAT_XO_CMD 1 // Set TX offset freq. +//#define CAT_XO_CMD 1 // Removed 2026-07-06: TX offset (XO command) — unused by the web app and by hamlib's TS-480 driver #endif // Lines below NEEDED FOR CW, removed to make space for CAT @@ -205,9 +205,7 @@ Global variables use 1499 bytes (73%) of dynamic memory, leaving 549 bytes for l ///G8RDI comment out FAST_AGC & DIAG below to save mem space for CAT //#define DIAG 1 // Hardware diagnostics on startup (use to debug problems) -#ifndef CAT_XO_CMD // Undefined CW_VOLUME to make space for CAT_XO_CMD -#define CW_VOLUME 1 // Enable separate CW tone volume in the menu -#endif +//#define CW_VOLUME 1 // Removed 2026-07-06 along with the rest of CW TX (was only auto-enabled when CAT_XO_CMD was off anyway) //#define CW_DECODER 1 // CW decoder (removed - not used on BLACK_BRICK) //#define CW_INTERMEDIATE 1 // CW decoder shows intermediate characters (only available for LCD and F_MCU at 20M), sequences like: EIS[HV] EIUF EAW[JP] EARL TMO TMG[ZQ] TND[BX] TNK[YC], may be good to learn CW; a full list of possible sequences: EISH5 EISV3 EIUF EIUU2 EAWJ1 EAWP EARL TMOO0 TMOO9 TMOO8 TMGZ7 TMGQ TNDB6 TNDX TNKY TNKC @@ -240,10 +238,12 @@ Global variables use 1499 bytes (73%) of dynamic memory, leaving 549 bytes for l // Advanced configuration switches //#define CONDENSED 1 // Display in 4 line mode (for OLED and LCD2004 modules) #define TX_ENABLE 1 // Disable this for RX only (no transmit), e.g. to support uSDX for kids idea: https://groups.io/g/ucx/topic/81030243#6276 -#define SEMI_QSK 1 // Just after keying the transmitter, keeps the RX muted for a short amount of time in the anticipation for continued keying -#define RIT_ENABLE 1 // Receive-In-Transit alternates the receiving frequency with an user-defined offset to compensate for any necessary tuning needed on receive -#define VOX_ENABLE 1 // Voice-On-Xmit which is switching the transceiver into transmit as soon audio is detected (above noise gate level) +//#define SEMI_QSK 1 // Removed 2026-07-06 with the rest of CW TX (CW is RX-only now) // Just after keying the transmitter, keeps the RX muted for a short amount of time in the anticipation for continued keying +//#define CW_TX 1 // Removed 2026-07-06: CW transmit (dsp_tx_cw/sidetone/key shaping) — this rig transmits SSB only, fed by external audio; CW RX (narrow filters + cw_offset tuning) is kept and keying TX in CW mode is blocked in switch_rxtx +//#define RIT_ENABLE 1 // Removed: digital-modes-only rig — RIT is pointless when the PC tunes via CAT // Receive-In-Transit alternates the receiving frequency with an user-defined offset to compensate for any necessary tuning needed on receive +//#define VOX_ENABLE 1 // Removed: digital-modes-only rig, PTT is CAT-controlled // Voice-On-Xmit which is switching the transceiver into transmit as soon audio is detected (above noise gate level) //#define MOX_ENABLE 1 // Monitor-On-Xmit which is audio monitoring on speaker during transmit +//#define AM_FM_TX 1 // Removed: digital-modes-only rig — AM/FM RX (listening) is kept, but their transmitters (dsp_tx_am/dsp_tx_fm) are gone and keying TX while in AM/FM is blocked in switch_rxtx (can't accidentally transmit over a broadcast station) //#define ONEBUTTON 1 // Use single (encoder) button to control full the rig; optionally use L/R buttons to completely replace rotory encoder function //#define DEBUG 1 // for development purposes only (adds debugging features such as CPU, sample-rate measurement, additional parameters) @@ -1913,9 +1913,9 @@ public: uint32_t P2; // Synth config register P2 uint32_t P3; // Synth config register P3 - P1 = (uint32_t)(128 * ((float)num / (float)denom)); + P1 = (128UL * num) / denom; // integer floor(128*num/denom) — exact per the SI5351 datasheet formula (num,denom <= 0xFFFFF so 128*num fits uint32); the old float version could round the ratio up and land one step off P1 = (uint32_t)(128 * (uint32_t)(divider) + P1 - 512); - P2 = (uint32_t)(128 * ((float)num / (float)denom)); + P2 = (128UL * num) / denom; P2 = (uint32_t)(128 * num - denom * P2); P3 = denom; @@ -2353,6 +2353,11 @@ volatile uint8_t tone_vol = 12; volatile uint8_t cw_tone = 1; const uint32_t tones[] = { F_MCU * 700ULL / 20000000, F_MCU * 600ULL / 20000000, F_MCU * 700ULL / 20000000 }; // G8RDI todo ULL to divisor? +void dummy() +{ +} + +#ifdef CW_TX volatile int8_t p_sin = 0; // initialized with A*sin(0) = 0 volatile int8_t n_cos = 448 / 4; // initialized with A*cos(t) = A inline void process_minsky() // Minsky circle sample [source: https://www.cl.cam.ac.uk/~am21/hakmemc.html, ITEM 149]: p_sin+=n_cos*2*PI*f/fs; n_cos-=p_sin*2*PI*f/fs; @@ -2365,14 +2370,10 @@ inline void process_minsky() // Minsky circle sample [source: https://www.cl.cam // CW Key-click shaping, ramping up/down amplitude with sample-interval of 60us. Tnx: Yves HB9EWY https://groups.io/g/ucx/message/5107 const uint8_t ramp[] PROGMEM = { 255, 254, 252, 249, 245, 239, 233, 226, 217, 208, 198, 187, 176, 164, 152, 139, 127, 115, 102, 90, 78, 67, 56, 46, 37, 28, 21, 15, 9, 5, 2 }; // raised-cosine(i) = 255 * sq(cos(HALF_PI * i/32)) -void dummy() -{ -} - void dsp_tx_cw() { // jitter dependent things first #ifdef KEY_CLICK - if (OCR1BL < lut[255]) { //check if already ramped up: ramp up of amplitude + if (OCR1BL < lut[255]) { //check if already ramped up: ramp up of amplitude for (uint16_t i = 31; i != 0; i--) { // soft rising slope against key-clicks OCR1BL = lut[pgm_read_byte_near(ramp[i])]; delayMicroseconds(60); @@ -2389,7 +2390,9 @@ void dsp_tx_cw() OCR1AL = (p_sin >> (16 - volume)) + 128; #endif } +#endif //CW_TX +#ifdef AM_FM_TX void dsp_tx_am() { // jitter dependent things first ADCSRA |= (1 << ADSC); // start next ADC conversion (trigger ADC interrupt if ADIE flag is set) @@ -2416,6 +2419,7 @@ void dsp_tx_fm() int16_t df = in; si5351.freq_calc_fast(df); // calculate SI5351 registers based on frequency shift and carrier frequency } +#endif //AM_FM_TX #define EA(y, x, one_over_alpha) (y) = (y) + ((x) - (y)) / (one_over_alpha); // exponental averaging [Lyons 13.33.1] #define MLEA(y, x, L, M) (y) = (y) + ((((x) - (y)) >> (L)) - (((x) - (y)) >> (M))); // multiplierless exponental averaging [Lyons 13.33.1], with alpha=1/2^L - 1/2^M @@ -2695,19 +2699,8 @@ volatile uint8_t att = 0; volatile uint8_t att2 = 0; // note: values >=2 help prevent numeric overflow on strong signals volatile uint8_t _init = 0; -// Old AGC algorithm which only increases gain, but does not decrease it for very strong signals. -// Maximum possible gain is x32 (in practice, x31) so AGC range is x1 to x31 = 30dB approx. -// Decay time is fine (about 1s) but attack time is much slower than I like. -// For weak/medium signals it aims to keep the sample value between 1024 and 2048. -static int16_t gain = 1024; -inline int16_t process_agc_fast(int16_t in) -{ - int16_t out = (gain >= 1024) ? (gain >> 10) * in : in; - int16_t accum = (1 - abs(out >> 10)); - if ((INT16_MAX - gain) > accum) gain = gain + accum; - if (gain < 1) gain = 1; - return out; -} +// The old gain-increase-only AGC algorithm (process_agc_fast, ~30dB range, slow attack) +// was removed 2026-07-06 — this build has a single AGC: the M0PUB algorithm below. // Contribution by Alan, M0PUB: Experimental new AGC algorithm. // ASSUMES: Input sample values are constrained to a maximum of +/-4096 to avoid integer overflow in earlier @@ -2731,6 +2724,11 @@ static uint16_t decayCount = DECAY_FACTOR; #define HI(x) ((x) >> 8) #define LO(x) ((x) & 0xFF) +// AGC target level ("AGC level" menu item, Slow mode only): the algorithm keeps output +// peaks inside the window [agc_lvl*256 .. agc_lvl*384]. Default 4 reproduces the original +// hardcoded window of 1024..1536; higher = louder output before AGC clamps. +volatile uint8_t agc_lvl = 4; + inline int16_t process_agc(int16_t in) { static bool small = true; @@ -2742,11 +2740,11 @@ inline int16_t process_agc(int16_t in) out = (centiGain >> 2) * (in >> 3); // net gain < 1 out >>= 2; - if (HI(abs(out)) > HI(1536)) { + if (HI(abs(out)) > (agc_lvl + (agc_lvl >> 1))) { // upper bound = agc_lvl*384 (default 4 -> HI(1536)) centiGain -= (centiGain >> 4); // Fast attack time when big signal encountered (relies on CentiGain >= 16) } else { - if (HI(abs(out)) > HI(1024)) + if (HI(abs(out)) > agc_lvl) // lower bound = agc_lvl*256 (default 4 -> HI(1024)) small = false; if (--decayCount == 0) { // But slow ramp up of gain when signal disappears if (small) { // 400 samples below lower threshold - increase gain @@ -3249,7 +3247,7 @@ inline int16_t slow_dsp(int16_t i_ac2, int16_t q_ac2) ac = ac - dc; */ static int16_t as_last; // GW8RDI mod - replaced LP filter: DC removal done in sdr_rx() - int16_t as = ac + (int16_t)((float)as_last * 0.9999f); // Reduce from 0.9999f for less bass response + int16_t as = ac + (int16_t)(((int32_t)as_last * 32765L) >> 15); // Q15 for the old 0.9999f pole (32765/32768) — integer keeps this per-sample path float-free (a float multiply costs ~100 cycles on AVR); lower the coefficient for less bass response ac = as - as_last; as_last = as; @@ -3304,20 +3302,10 @@ inline int16_t slow_dsp(int16_t i_ac2, int16_t q_ac2) if (!(absavg256cnt--)) { _absavg256 = absavg256; absavg256 = 0; } // Set S-Meter level else absavg256 += abs(acm); // G8RDI mod - acm -#ifdef FAST_AGC - if (agc == 2) { + if (agc) { ac = process_agc(ac); ac = ac >> (16 - volume); } - else if (agc == 1) { - ac = process_agc_fast(ac); - ac = ac >> (16 - volume); -#else - if (agc == 1) { - ac = process_agc_fast(ac); - ac = ac >> (16 - volume); -#endif //!FAST_AGC - } else { //ac = ac >> (16-volume); if (volume <= 13) // if no AGC allow volume control to boost weak signals @@ -4209,10 +4197,8 @@ volatile bool change = true; volatile bool changedMode = 0; volatile bool changedModeCAT = 0; volatile int32_t freq = 14000000; -static int32_t vfo[] = { 7074000, 14074000 }; -static uint8_t vfomode[] = { USB, USB }; // default both VFOs to USB -enum vfo_t { VFOA = 0, VFOB = 1, SPLIT = 2 }; -volatile uint8_t vfosel = VFOA; +// VFO B / Split removed 2026-07-06: single-VFO rig (CAT tunes `freq` directly via FA;). +// FREQA/MODEA persist the live freq/mode; FREQB/MODEB param slots were dropped. volatile int32_t rit = 0; // GW8RDI mod - changed to int32_t from int16_t #ifdef CAT_XO_CMD volatile int32_t tit = 0; // GW8RDI mod - added Transit offset, used with Quantum Spectrum module @@ -4237,10 +4223,25 @@ int16_t smeter(int16_t ref = 0) // activity — see cat_active handling). Only the LCD writes below stay // gated on smode, since those are purely a display concern. if ((++smeter_cnt % 2048) == 0) { // slowed down display slightly - float rms = (float)max_absavg256 * (float)(1 << att2); - if (dsp_cap == SDR) rms /= (256.0 * 1024.0 * (float)R * 8.0 * 500.0 * 1.414 / (0.707 * 1.1)); // = -98.8dB 1 rx gain stage: rmsV = ADC value * AREF / [ADC DR * processing gain * receiver gain * "RMS compensation"] - else rms /= (256.0 * 1024.0 * (float)R * 2.0 * 100.0 * 120.0 / (1.750 * 5.0)); // = -94.6dB - dbm = 10 * log10((rms * rms) / 50) + 30 - ref; //from rmsV to dBm at 50R + // Integer dBm (2026-07-06): replaces the old float+log10 version — dropping the + // only libm log10 user (and, with the other two float sites converted, the whole + // float-support library) from the build. Same math, log2-based: + // dbm = 20*log10(rms) + 13.01, rms = max_absavg256 * 2^att2 / K + // = 6.0206 * log2(max_absavg256 * 2^att2) - (20*log10(K) - 13.01) + // K(SDR) = 256*1024*R*8*500*1.414/(0.707*1.1) = 7.63e9 -> constant -184 + // K(analog) = 256*1024*R*2*100*120/(1.750*5.0) = 2.88e9 -> constant -175 + // (constants tuned +1 vs the exact -184.6/-175.6 to match the old float + // version's truncation-toward-zero of negative dBm; validated numerically + // against the float formula: 60% exact, rest within +/-1 dB) + // log2 in 1/16 steps (0.376 dB granularity) via MSB position + 4 mantissa bits. + uint32_t x = max_absavg256 * (uint32_t)(1UL << att2); + if (!x) x = 1; + uint8_t msb = 0; for (uint32_t v = x; v >>= 1;) msb++; + static const uint8_t log2frac16[16] PROGMEM = { 0, 1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15 }; // round(16*log2(1 + i/16)) + uint8_t frac = (msb >= 4) ? (x >> (msb - 4)) & 0x0F : (x << (4 - msb)) & 0x0F; + uint16_t l2_16 = ((uint16_t)msb << 4) + pgm_read_byte(&log2frac16[frac]); + // 6.0206 dB/octave / 16 steps = 0.376288 ~= 385/1024 + dbm = (int16_t)(((int32_t)l2_16 * 385 + 512) >> 10) + ((dsp_cap == SDR) ? -184 : -175) - ref; max_absavg256 /= 2; // Implement peak hold/decay for all meter types — must run every cycle regardless of smode, not just when the LCD display branches below execute if (!smode) return dbm; @@ -4341,6 +4342,9 @@ uint32_t semi_qsk_timeout = 0; // Set RX or TX mode, with RIT support and CW offset void switch_rxtx(uint8_t tx_enable) { +#ifndef AM_FM_TX + if (tx_enable && (mode != LSB) && (mode != USB)) return; // Only SSB can key TX in this build: AM/FM/CW are RX-only (listening) modes — never transmit over the station being received, whether from PTT or CAT TX;. All TX audio comes from an external source via the mic input (digital modes). +#endif //!AM_FM_TX //GW8RDI - NOTE: Best use the PA line to trigger the Spectrum mode instead of CAT as gives near instant audio control. TX RFI can interfere with RS232 as TX starts to xmit. #ifdef CAT_TX_CMD if (cat_enabled && mode != CW) // To mute Spectrum DSP audio in CW mode connect the PA line to the Spectrum module PA input. @@ -4400,9 +4404,15 @@ void switch_rxtx(uint8_t tx_enable) { case USB: case LSB: func_ptr = dsp_tx; break; +#ifdef CW_TX case CW: func_ptr = dsp_tx_cw; break; +#endif //CW_TX +#ifdef AM_FM_TX case AM: func_ptr = dsp_tx_am; break; case FM: func_ptr = dsp_tx_fm; break; +#else + default: func_ptr = dsp_tx; break; // unreachable (AM/FM TX blocked at function entry) but func_ptr must never be left unset during TX +#endif //AM_FM_TX } } else @@ -4454,10 +4464,12 @@ void switch_rxtx(uint8_t tx_enable) lcd.setCursor(15, 1); lcd.print('T'); // Show Transmitting on LCD +#ifdef CW_TX if (mode == CW) { si5351.freq_calc_fast(-cw_offset); si5351.SendPLLRegisterBulk(); } // for CW, TX at freq +#endif //CW_TX (CW is RX-only in this build — TX in CW mode is blocked at function entry) #ifdef RIT_ENABLE // GW8RDI mod - restore freq else { @@ -4644,8 +4656,7 @@ void show_banner() { lcd.print('\x01'); lcd_blanks(); lcd_blanks(); } -const char* vfosel_label[] = { "A", "B"/*, "Split"*/ }; -///const char* vfosel_label[] = { "A", "B", "Split" }; // GW8RDI note - to add Split to the menu, will need a control adding to show mode, and change receive offset (int16_t rit) +//const char* vfosel_label[] = { "A", "B"/*, "Split"*/ }; // removed with VFO B/Split const char* mode_label[5] = { "LSB", "USB", "CW ", "FM ", "AM " }; // Display frequency on LCD. If RIT is enabled, displays just the receiver offset unless in TX @@ -4653,7 +4664,7 @@ inline void display_vfo(int32_t f) { lcd.setCursor(0, 1); - lcd.print((rit) ? ' ' : ((vfosel % 2) | ((vfosel == SPLIT) & tx)) ? '\x07' : '\x06'); // RIT, VFO A/B + lcd.print('\x06'); // single-VFO build: always the VFO-A glyph (RIT and VFO B removed) #ifdef CAT_XO_CMD if (tx && tit != 0) // GW8RDI mod - TX offset @@ -4823,7 +4834,7 @@ static uint8_t vox_sample = 0; static uint16_t vox_adc = 0; static uint8_t pwm_min = 10; -static uint8_t pwm_max = 160; // PWM value for which PA reaches its maximum: 128 for BS170, 160 for IRFI510G +static uint8_t pwm_max = 130; // PWM value for which PA reaches its maximum: 128 for BS170, 160 for IRFI510G; 130 default for this BLACK_BRICK unit (PU7FTW 2026-07-06) const char* offon_label[2] = { "OFF", "ON" }; #if(F_MCU > 16000000) @@ -4854,17 +4865,17 @@ const char* cw_tone_label[] = { "700", "600" }; #ifdef KEYER const char* keyer_mode_label[] = { "IambicA", "IambicB","Straight" }; // GW8RDI mod - byte saving was "Iambic A" #endif -const char* agc_label[] = { "OFF", "Fast", "Slow" }; +//const char* agc_label[] = { "OFF", "Fast", "Slow" }; // removed with the two-algorithm AGC — plain offon_label is used now #define _N(a) sizeof(a)/sizeof(a[0]) #define N_PARAMS 44 // last visible menu param enum value (BACKL=44); update when adding/removing menu items #ifdef KEEP_BAND_DATA #define I_PARAMS 5+9 -enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, BAND_DATA0, BAND_DATA1, BAND_DATA2, BAND_DATA3, BAND_DATA4, BAND_DATA5, BAND_DATA6, BAND_DATA7, BAND_DATA8, ALL = 0xff }; +enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, AGC_LVL, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, BAND_DATA0, BAND_DATA1, BAND_DATA2, BAND_DATA3, BAND_DATA4, BAND_DATA5, BAND_DATA6, BAND_DATA7, BAND_DATA8, ALL = 0xff }; #else #define I_PARAMS 5 -enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, ALL = 0xff }; +enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, AGC_LVL, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, ALL = 0xff }; #endif #define N_ALL_PARAMS (N_PARAMS+I_PARAMS) // number of parameters @@ -4877,20 +4888,17 @@ int8_t paramAction(uint8_t action, uint8_t id = ALL) // list of parameters if (id == ALL) for (id = 1; id != N_ALL_PARAMS + 1; id++) paramAction(action, id); // for all parameters switch (id) { // Visible parameters - case VOLUME: paramAction(action, volume, 0x11, F("Vol"), NULL, -1, 16, false); break; // GW8RDI mod - "Volume" + case VOLUME: paramAction(action, volume, 0x11, F("Volume"), NULL, -1, 16, false); break; case MODE: paramAction(action, mode, 0x12, F("Mode"), mode_label, 0, _N(mode_label) - 1, false); break; case FILTER: paramAction(action, filt, 0x13, F("FilterBW"), filt_label, 0, _N(filt_label) - 1, false); break; case BAND: paramAction(action, bandval, 0x14, F("Band"), band_label, 1, _N(band_label) - 2, false); break; case STEP: paramAction(action, stepsize, 0x15, F("Tune Rate"), stepsize_label, 0, _N(stepsize_label) - 1, false); break; - case VFOSEL: paramAction(action, vfosel, 0x16, F("VFO Mode"), vfosel_label, 0, _N(vfosel_label) - 1, false); break; + // VFOSEL menu removed 2026-07-06 (single-VFO build) #ifdef RIT_ENABLE case RIT: paramAction(action, rit, 0x17, F("RIT"), offon_label, 0, 1, false); break; #endif -#ifdef FAST_AGC - case AGC: paramAction(action, agc, 0x18, F("AGC"), agc_label, 0, _N(agc_label) - 1, false); break; -#else - case AGC: paramAction(action, agc, 0x18, F("AGC"), offon_label, 0, 1, false); break; -#endif // FAST_AGC + case AGC: paramAction(action, agc, 0x17, F("AGC"), offon_label, 0, 1, false); break; // moved to menu 1.7 (freed by RIT removal) so AGC level can sit next to it; ON = M0PUB slow-decay algorithm + case AGC_LVL: paramAction(action, agc_lvl, 0x18, F("AGC level"), NULL, 1, 14, false); break; // AGC target level: output peaks held in [lvl*256 .. lvl*384]; default 4 = original 1024..1536 window #ifdef NR_FIR case NR: if ((int32_t)nr + encoder_val < 3) @@ -4898,15 +4906,15 @@ int8_t paramAction(uint8_t action, uint8_t id = ALL) // list of parameters else paramAction(action, nr, 0x19, F("DSP NR"), NULL, 0, 8, false); #else - case NR: paramAction(action, nr, 0x19, F("NR"), NULL, 0, 8, false); + case NR: paramAction(action, nr, 0x19, F("Noise Reduce"), NULL, 0, 8, false); // 12 chars = exact visible fit after the "1.9 " menu prefix on the 16x2 LCD ("Noise reduction" would truncate) #endif #ifdef NR_FIR if (nr > 2) FirFilterSetup(7 + (((nr - 2) - 1) * 2), filt_val[filt], F_SAMP_RX / 8); // GW8RDI mod #endif break; - case ATT: paramAction(action, att, 0x1A, F("ATT"), att_label, 0, 7, false); break; - case ATT2: paramAction(action, att2, 0x1B, F("ATT2"), NULL, 0, 16, false); break; + case ATT: paramAction(action, att, 0x1A, F("Analog Att"), att_label, 0, 7, false); break; // menu 1.10: 5-char prefix leaves 11 visible chars — "Analog attenuation" would truncate + case ATT2: paramAction(action, att2, 0x1B, F("Digital Att"), NULL, 0, 16, false); break; // menu 1.11: 11 chars = exact visible fit case SMETER: paramAction(action, smode, 0x1C, F("S-Meter"), smode_label, 0, _N(smode_label) - 1, false); break; #ifdef CW_DECODER case CWDEC: paramAction(action, cwdec, 0x21, F("CW Decoder"), offon_label, 0, 1, false); break; @@ -4949,8 +4957,7 @@ int8_t paramAction(uint8_t action, uint8_t id = ALL) // list of parameters #endif #endif case PWM_MIN: paramAction(action, pwm_min, 0x81, F("PA bias min"), NULL, 0, pwm_max - 1, false); break; - case PWM_MAX: paramAction(action, pwm_max, 0x82, F("PA max"), NULL, pwm_min, 255, false); break; - ///case PWM_MAX: paramAction(action, pwm_max, 0x82, F("PA Bias max"), NULL, pwm_min, 255, false); break; + case PWM_MAX: paramAction(action, pwm_max, 0x82, F("PA bias max"), NULL, pwm_min, 255, false); break; case SIFXTAL: paramAction(action, si5351.fxtal, 0x83, F("Ref frq"), NULL, 14000000, 28000000, false); break; case IQ_ADJ: paramAction(action, rx_ph_q, 0x84, F("IQ phase"), NULL, 0, 180, false); break; #ifdef CAL_IQ @@ -4971,10 +4978,8 @@ int8_t paramAction(uint8_t action, uint8_t id = ALL) // list of parameters #endif case BACKL: paramAction(action, backlight, 0xA1, F("Light"), offon_label, 0, 1, false); break; // GW8RDI "Backlight" workaround for varying N_PARAM and not being able to overflowing default cases properly // Invisible parameters - case FREQA: paramAction(action, vfo[VFOA], 0, NULL, NULL, 0, 0, false); break; - case FREQB: paramAction(action, vfo[VFOB], 0, NULL, NULL, 0, 0, false); break; - case MODEA: paramAction(action, vfomode[VFOA], 0, NULL, NULL, 0, 0, false); break; - case MODEB: paramAction(action, vfomode[VFOB], 0, NULL, NULL, 0, 0, false); break; + case FREQA: paramAction(action, freq, 0, NULL, NULL, 0, 0, false); break; // single-VFO: persists the live frequency directly + case MODEA: paramAction(action, mode, 0, NULL, NULL, 0, 0, false); break; // single-VFO: persists the live mode directly (FREQB/MODEB dropped) case VERS: paramAction(action, eeprom_version, 0, NULL, NULL, 0, 0, false); break; /* #ifdef KEEP_BAND_DATA case BAND_DATA0: // G8RDI mod - added @@ -5099,6 +5104,8 @@ void analyseCATcmd() // Supported Kenwood TS-480 protocol CAT commands else if (CMD('T','X','2')) Command_TX2(); else if (CMD4('A','G','0',';')) Command_AG0_GET(); else if (CMD('A','G','0') && CATcmd[4]==';') Command_AG0_SET(); + else if (CMD('A','L',';')) Command_AL_GET(); + else if (CMD2('A','L') && CATcmd[2]!=';') Command_AL_SET(); else if (CMD('F','W',';')) Command_FW_GET(); else if (CMD2('F','W') && CATcmd[3]==';') Command_FW_SET(); else if (CMD('X','T','1')) Command_XT1(); @@ -5165,7 +5172,7 @@ void serialEvent() { CATcmd[cat_ptr] = '\0'; // terminate the array cat_ptr = 0; // reset for next CAT command #ifdef _SERIAL - if (!cat_active) { cat_active = 1; smode = 0; } // disable smeter to reduce display activity + if (!cat_active) { cat_active = 1; } // was also forcing smode = 0 here to reduce display activity, but smode never got restored, so the LCD S-meter/dBm field froze permanently once CAT started polling (dbm itself keeps updating live for SM; regardless of smode, see smeter() comment) #endif analyseCATcmd(); delay(10); @@ -5440,16 +5447,33 @@ void Command_VO_SET() void Command_AG0_GET() { char buf[6]; - sprintf(buf, "AG0%u;", (uint8_t)agc); // 0=OFF 1=Fast 2=Slow + sprintf(buf, "AG0%u;", (uint8_t)agc); // 0=OFF 1=ON (M0PUB slow-decay AGC; the old 2=Slow tri-state is gone) Serial.print(buf); } void Command_AG0_SET() { uint8_t v = CATcmd[3] - '0'; - if (v <= 2) { agc = v; paramAction(SAVE, AGC); } + if (v <= 1) { agc = v; paramAction(SAVE, AGC); } Command_AG0_GET(); } +// AL; → get AGC level, ALn; → set AGC level (1..14). Target window for the AGC: +// output peaks held between level*256 and level*384 (default 4 = 1024..1536). +// Plain variable read in slow_dsp()'s process_agc() every sample — no hardware +// re-apply needed (same live-variable pattern as DR/A2). +void Command_AL_GET() +{ + char buf[7]; + sprintf(buf, "AL%u;", (uint8_t)agc_lvl); + Serial.print(buf); +} +void Command_AL_SET() +{ + uint8_t v = (uint8_t)atoi(CATcmd + 2); + if (v >= 1 && v <= 14) { agc_lvl = v; paramAction(SAVE, AGC_LVL); } + Command_AL_GET(); +} + // FW; → get filter index (0..7), FWn; → set filter index void Command_FW_GET() { @@ -5894,8 +5918,7 @@ void setup() vox = false; // disable VOX //nr = 2; // set 2 default / 0 disable NR rit = false; // disable RIT - freq = vfo[vfosel % 2]; - mode = vfomode[vfosel % 2]; + // single-VFO: freq/mode were already populated by paramAction(LOAD, ALL) via FREQA/MODEA #ifdef NR_FIR if (nr > 2) @@ -6191,8 +6214,7 @@ void loop() filt = prev_filt[mode == CW]; // backup filter setting for previous mode, restore previous filter setting for current selected mode; filter settings captured for either CQ or other modes. #endif //paramAction(UPDATE, MODE); - vfomode[vfosel % 2] = mode; - paramAction(SAVE, (vfosel % 2) ? MODEB : MODEA); // save vfoa/b changes + paramAction(SAVE, MODEA); // single-VFO: persist mode paramAction(SAVE, MODE); paramAction(SAVE, FILTER); si5351.iqmsa = 0; // enforce PLL reset @@ -6250,21 +6272,8 @@ void loop() //for(;micros() < next;); next = micros() + 16; // sync every 1000000/62500=16ms (or later if missed) } // #endif //SIMPLE_RX -#ifdef RIT_ENABLE - rit = !rit; - stepsize = (rit) ? STEP_10 : prev_stepsize[mode == CW]; - if (!rit) { // after RIT comes VFO A/B swap -#else - { -#endif //RIT_ENABLE - vfosel = !vfosel; - freq = vfo[vfosel % 2]; // todo: share code with menumode - mode = vfomode[vfosel % 2]; - // make more generic: - if (mode != CW) stepsize = STEP_1k; else stepsize = STEP_500; - if (mode == CW) { filt = 4; nr = 0; } - else filt = 0; - } + // RIT toggle and VFO A/B swap removed 2026-07-06 (single-VFO build) — this + // double-click is now a no-op apart from a display refresh. change = true; break; //#define TUNING_DIAL 1 @@ -6300,8 +6309,8 @@ void loop() prev_mode = mode; if (bandval > 0 && bandval <= BANDCOUNT) // bandval 1-5/8 (0 is 6m, 9 is 160m) { - freq_last[bandval - 1] = freq; //vfo[vfosel % 2] // G8RDI mod - Save freq and mode last used on this band - mode_last[bandval - 1] = vfomode[vfosel % 2]; + freq_last[bandval - 1] = freq; // G8RDI mod - Save freq and mode last used on this band + mode_last[bandval - 1] = mode; } #ifdef DEBUG_G8RDI else @@ -6379,8 +6388,7 @@ void loop() prev_filt[prev_mode == CW] = filt; filt = prev_filt[mode == CW]; // backup filter setting for previous mode, restore previous filter setting for current selected mode; filter settings captured for either CQ or other modes. #endif //paramAction(UPDATE, MODE); - vfomode[vfosel % 2] = mode; - paramAction(SAVE, (vfosel % 2) ? MODEB : MODEA); // save vfoa/b changes + paramAction(SAVE, MODEA); // single-VFO: persist mode paramAction(SAVE, MODE); paramAction(SAVE, FILTER); si5351.iqmsa = 0; // enforce PLL reset @@ -6473,8 +6481,7 @@ void loop() filt = prev_filt[mode == CW]; // backup filter setting for previous mode, restore previous filter setting for current selected mode; filter settings captured for either CQ or other modes. #endif //paramAction(UPDATE, MODE); - vfomode[vfosel % 2] = mode; - paramAction(SAVE, (vfosel % 2) ? MODEB : MODEA); // save vfoa/b changes + paramAction(SAVE, MODEA); // single-VFO: persist mode paramAction(SAVE, MODE); paramAction(SAVE, FILTER); si5351.iqmsa = 0; // enforce PLL reset @@ -6517,8 +6524,7 @@ void loop() if (encoder_change) { lcd.setCursor(0, 1); lcd.cursor(); // edits menu item value; make cursor visible if (menu == MODE) { // post-handling Mode parameter - vfomode[vfosel % 2] = mode; - paramAction(SAVE, (vfosel % 2) ? MODEB : MODEA); // save vfoa/b changes + paramAction(SAVE, MODEA); // single-VFO: persist mode change = true; si5351.iqmsa = 0; // enforce PLL reset // make more generic: @@ -6530,15 +6536,7 @@ void loop() change = true; } //if(menu == NR){ if(mode == CW) nr = false; } - if (menu == VFOSEL) { - freq = vfo[vfosel % 2]; - mode = vfomode[vfosel % 2]; - // make more generic: - if (mode != CW) stepsize = STEP_1k; else stepsize = STEP_500; - if (mode == CW) { filt = 4; nr = 0; } - else filt = 0; - change = true; - } + // menu == VFOSEL post-handler removed 2026-07-06 (single-VFO build) #ifdef RIT_ENABLE if (menu == RIT) { stepsize = (rit) ? STEP_10 : STEP_500; @@ -6662,14 +6660,12 @@ void loop() freq = band[bandval]; // Change to new band freq start #endif //paramAction(UPDATE, MODE); - vfomode[vfosel % 2] = mode; - paramAction(SAVE, (vfosel % 2) ? MODEB : MODEA); // save vfoa/b changes + paramAction(SAVE, MODEA); // single-VFO: persist mode paramAction(SAVE, MODE); paramAction(SAVE, FILTER); si5351.iqmsa = 0; // enforce PLL reset } - vfo[vfosel % 2] = freq; //save_event_time = millis() + 1000; // schedule time to save freq (no save while tuning, hence no EEPROM wear out - G8RDI "Datasheet: Write/erase cycles: 10,000 flash/100,000 EEPROM") save_event_time = millis() + 2000; // G8RDI mod - increased to 2 seconds // schedule time to save freq (no save while tuning, hence no EEPROM wear out - G8RDI "Datasheet: Write/erase cycles: 10,000 flash/100,000 EEPROM") @@ -6706,11 +6702,11 @@ void loop() } if (save_event_time && (millis() > save_event_time)) { // save freq when time has reached schedule - reduce EEPROM writes as 10k limit to burnout (G8RDI)! - paramAction(SAVE, (vfosel % 2) ? FREQB : FREQA); // save vfoa/b changes + paramAction(SAVE, FREQA); // single-VFO: persist frequency #ifdef KEEP_BAND_DATA // G8RDI mod - freq_last[bandval - 1] = vfo[vfosel % 2]; // = freq; - mode_last[bandval - 1] = vfomode[vfosel % 2]; // = mode; + freq_last[bandval - 1] = freq; + mode_last[bandval - 1] = mode; /* 230401 switch (bandval - 1) // G8RDI mod - added Save only changed diff --git a/scripts/cat-hardware-test.ts b/scripts/cat-hardware-test.ts index 449c84c..e96c0ec 100644 --- a/scripts/cat-hardware-test.ts +++ b/scripts/cat-hardware-test.ts @@ -1,5 +1,5 @@ /** - * CAT hardware test bed — uSDX BLACK_BRICK 4.00h. + * CAT hardware test bed — uSDX BLACK_BRICK 4.01a. * * Talks to the real, flashed radio over its CAT serial port and validates * that live behavior matches what src/lib/cat/__tests__/protocol.test.ts @@ -56,15 +56,37 @@ function send(fd: number, cmd: string, waitMs = 400): string { return resp; } +// For pure GET queries only (idempotent, no radio-state side effects): the +// LCD/UART pin sharing on this board (see parseIntField comment) can +// occasionally drop a reply on the floor, not just corrupt trailing bytes. +// The real app rides this out by retrying on its next poll cycle +// (useRadioCAT.ts); mirror that here with one bounded resend instead of +// failing the check on a transient hardware glitch. Never use this for a SET +// command — resending one could double-apply it. +function sendGet(fd: number, cmd: string, prefix: string, waitMs = 400): string { + const resp = send(fd, cmd, waitMs); + if (resp.includes(prefix)) return resp; + return send(fd, cmd, waitMs); +} + // ── Same parse helpers as useRadioCAT.ts / protocol.test.ts ────────────────── +// This board's LCD shares its data pins with the UART (see usdxBLACKBRICK.ino +// pre()/post(), "NOISE LEAK INTO RX!!!"): an LCD write can briefly disable the +// UART mid-transmission, leaving a stray noise byte trailing a reply. The real +// app (useRadioCAT.ts) tolerates this by timing out and resyncing; here we just +// ignore anything after the frame's own terminator instead of requiring EOL. function parseIntField(resp: string, prefix: string): number | null { - const m = resp.match(new RegExp(`^${prefix}(-?\\d+);$`)); + const m = resp.match(new RegExp(`${prefix}(-?\\d+);`)); return m ? parseInt(m[1], 10) : null; } +// Trailing fragments that aren't a real `XX` frame are LCD/UART pin-share +// noise (see parseIntField comment), not a protocol frame — drop them. +// NOTE: prefixes are letter+letter-or-digit ("A2" is a real command prefix), +// not two letters — /^[A-Z]{2}/ silently dropped every A2 frame. function splitFrames(raw: string): string[] { - return raw.split(';').filter(Boolean).map(f => f + ';'); + return raw.split(';').filter(f => /^[A-Z][A-Z0-9]/.test(f)).map(f => f + ';'); } function framesByPrefix(frames: string[]): Map { @@ -76,7 +98,7 @@ function framesByPrefix(frames: string[]): Map { // BL (backlight) is polled again since the 2026-07-04 firmware fix (BACKLIGHT_PIN // moved to the correct pin, PD3). PM/PX (PA bias) are deliberately NOT polled — // the app fetches them on demand when its PA settings panel opens. -const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;']; +const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;']; // ── Test bed ────────────────────────────────────────────────────────────────── @@ -103,16 +125,27 @@ function main(): void { readAvailable(fd, 300); // ── IF; sanity ── - const ifResp = send(fd, 'IF;'); - const ifOk = /^IF\d{11}00000\+0000000000\d\d000000;$/.test(ifResp); + const ifResp = sendGet(fd, 'IF;', 'IF'); + const ifOk = /IF\d{11}00000\+0000000000\d\d000000;/.test(ifResp); record('IF; returns well-formed 38-char status frame', ifOk, JSON.stringify(ifResp)); - // ── Batched poll returns all frames in order ── - const pollResp = send(fd, BLACKBRICK_POLL_CMDS.join(''), 600); - const frames = splitFrames(pollResp); - const map = framesByPrefix(frames); + // ── Batched poll returns all frames in order. This board's LCD/UART pin + // sharing (see parseIntField comment) can occasionally drop a sub-command's + // reply entirely, not just corrupt trailing bytes — the real app tolerates + // this by timing out the whole batch and retrying on its next poll cycle + // (useRadioCAT.ts), so mirror that here with one bounded retry rather than + // failing on a transient hardware glitch. The poll is a pure GET batch, so + // resending it has no side effects. ── const expectedPrefixes = BLACKBRICK_POLL_CMDS.map(c => c.substring(0, 2)); - const gotPrefixes = frames.map(f => f.substring(0, 2)); + let pollResp = send(fd, BLACKBRICK_POLL_CMDS.join(''), 600); + let frames = splitFrames(pollResp); + let gotPrefixes = frames.map(f => f.substring(0, 2)); + if (JSON.stringify(gotPrefixes) !== JSON.stringify(expectedPrefixes)) { + pollResp = send(fd, BLACKBRICK_POLL_CMDS.join(''), 600); + frames = splitFrames(pollResp); + gotPrefixes = frames.map(f => f.substring(0, 2)); + } + const map = framesByPrefix(frames); record( `Batched poll returns all ${BLACKBRICK_POLL_CMDS.length} frames in order`, JSON.stringify(gotPrefixes) === JSON.stringify(expectedPrefixes), @@ -137,7 +170,7 @@ function main(): void { ); // ── SET/GET round-trip on the analog attenuator (AT), restoring original ── - const beforeResp = send(fd, 'AT;'); + const beforeResp = sendGet(fd, 'AT;', 'AT'); const before = parseIntField(beforeResp, 'AT'); record('AT; GET returns a value before round-trip', before !== null, JSON.stringify(beforeResp)); @@ -147,7 +180,7 @@ function main(): void { const setEchoed = parseIntField(setResp, 'AT'); record('AT SET echoes the new value', setEchoed === probe, JSON.stringify(setResp)); - const confirmResp = send(fd, 'AT;'); + const confirmResp = sendGet(fd, 'AT;', 'AT'); const confirmed = parseIntField(confirmResp, 'AT'); record('AT GET reflects the newly set value', confirmed === probe, JSON.stringify(confirmResp)); @@ -161,7 +194,7 @@ function main(): void { // was active, and CAT mode forces that display off. Cross-validated by // driving a real hardware change (max analog attenuation, -73dB) and // confirming the SM; reading actually moves in response, then restoring. ── - const smBeforeResp = send(fd, 'SM;'); + const smBeforeResp = sendGet(fd, 'SM;', 'SM'); const smBefore = parseIntField(smBeforeResp, 'SM'); record('SM; GET returns a reading before attenuator change', smBefore !== null, JSON.stringify(smBeforeResp)); @@ -179,7 +212,7 @@ function main(): void { } // ── SET/GET round-trip on TX drive/power (DR), restoring original ── - const drBeforeResp = send(fd, 'DR;'); + const drBeforeResp = sendGet(fd, 'DR;', 'DR'); const drBefore = parseIntField(drBeforeResp, 'DR'); record('DR; GET returns a value before round-trip', drBefore !== null, JSON.stringify(drBeforeResp)); @@ -189,7 +222,7 @@ function main(): void { const setEchoed = parseIntField(setResp, 'DR'); record('DR SET echoes the new value', setEchoed === probe, JSON.stringify(setResp)); - const confirmResp = send(fd, 'DR;'); + const confirmResp = sendGet(fd, 'DR;', 'DR'); const confirmed = parseIntField(confirmResp, 'DR'); record('DR GET reflects the newly set value', confirmed === probe, JSON.stringify(confirmResp)); @@ -201,7 +234,7 @@ function main(): void { // ── SET/GET round-trip on the backlight (BL), restoring original. // Regression check for the PD3 pin fix — the wire round-trip always worked, // this validates the protocol; the physical LED needs eyes on the radio. ── - const blBeforeResp = send(fd, 'BL;'); + const blBeforeResp = sendGet(fd, 'BL;', 'BL'); const blBefore = parseIntField(blBeforeResp, 'BL'); record('BL; GET returns 0/1', blBefore === 0 || blBefore === 1, JSON.stringify(blBeforeResp)); @@ -213,13 +246,69 @@ function main(): void { record('BL restored to its original value', parseIntField(blRestoreResp, 'BL') === blBefore, JSON.stringify(blRestoreResp)); } + // ── Mode changes over CAT (MD, Kenwood digits 1=LSB 2=USB 3=CW 4=FM 5=AM). + // Since the 2026-07-06 build AM/FM/CW are selectable for RX (listening) while + // TX is blocked outside LSB/USB *in the firmware* — TX blocking is deliberately + // NOT exercised here (never key the PA in a test; validate manually with a + // dummy load). An out-of-range digit must clamp to LSB (MD1). ── + const mdBeforeResp = sendGet(fd, 'MD;', 'MD'); + const mdBefore = parseIntField(mdBeforeResp, 'MD'); + record('MD; GET returns a Kenwood mode digit', mdBefore !== null && mdBefore >= 1 && mdBefore <= 5, JSON.stringify(mdBeforeResp)); + + if (mdBefore !== null) { + send(fd, 'MD5;'); // AM — RX-only mode, must be selectable + sleepMs(150); // clamping happens in the main loop, give it a beat + const mdAm = sendGet(fd, 'MD;', 'MD'); + record('MD5 (AM) is selectable for RX', parseIntField(mdAm, 'MD') === 5, JSON.stringify(mdAm)); + + send(fd, 'MD4;'); // FM — RX-only mode, must be selectable + sleepMs(150); + const mdFm = sendGet(fd, 'MD;', 'MD'); + record('MD4 (FM) is selectable for RX', parseIntField(mdFm, 'MD') === 4, JSON.stringify(mdFm)); + + send(fd, 'MD9;'); // out of range — must clamp to LSB + sleepMs(150); + const mdClamped = sendGet(fd, 'MD;', 'MD'); + record('MD9 (out of range) clamps to LSB (MD1)', parseIntField(mdClamped, 'MD') === 1, JSON.stringify(mdClamped)); + + send(fd, `MD${mdBefore};`); + sleepMs(150); + const mdRestored = sendGet(fd, 'MD;', 'MD'); + record('MD restored to its original value', parseIntField(mdRestored, 'MD') === mdBefore, JSON.stringify(mdRestored)); + } + + // ── SET/GET round-trip on the AGC level (AL, 1..14), restoring original. + // Also checks the reject-echo path (AL0 is out of range → old value). ── + const alBeforeResp = sendGet(fd, 'AL;', 'AL'); + const alBefore = parseIntField(alBeforeResp, 'AL'); + record('AL; GET returns a value in 1..14', alBefore !== null && alBefore >= 1 && alBefore <= 14, JSON.stringify(alBeforeResp)); + + if (alBefore !== null) { + const alProbe = alBefore === 4 ? 6 : 4; + const alSetResp = send(fd, `AL${alProbe};`); + record('AL SET echoes the new value', parseIntField(alSetResp, 'AL') === alProbe, JSON.stringify(alSetResp)); + const alRestoreResp = send(fd, `AL${alBefore};`); + record('AL restored to its original value', parseIntField(alRestoreResp, 'AL') === alBefore, JSON.stringify(alRestoreResp)); + const alRejectResp = send(fd, 'AL0;'); + record('AL SET of 0 is rejected (echo returns old value)', parseIntField(alRejectResp, 'AL') === alBefore, JSON.stringify(alRejectResp)); + } + + // ── AG0 SET beyond 1 must be rejected since the single-algorithm AGC change ── + const agBeforeResp = sendGet(fd, 'AG0;', 'AG0'); + const agBefore = parseIntField(agBeforeResp, 'AG0'); + record('AG0; GET returns 0/1', agBefore === 0 || agBefore === 1, JSON.stringify(agBeforeResp)); + if (agBefore !== null) { + const agRejectResp = send(fd, 'AG02;'); + record('AG0 SET of 2 is rejected (echo returns old value)', parseIntField(agRejectResp, 'AG0') === agBefore, JSON.stringify(agRejectResp)); + } + // ── SET/GET round-trips on the PA bias endpoints (PM/PX), restoring // originals. Safe in RX: the rebuilt PWM LUT is only consumed during TX. ── - const pmBeforeResp = send(fd, 'PM;'); + const pmBeforeResp = sendGet(fd, 'PM;', 'PM'); const pmBefore = parseIntField(pmBeforeResp, 'PM'); record('PM; GET returns a value', pmBefore !== null, JSON.stringify(pmBeforeResp)); - const pxBeforeResp = send(fd, 'PX;'); + const pxBeforeResp = sendGet(fd, 'PX;', 'PX'); const pxBefore = parseIntField(pxBeforeResp, 'PX'); record('PX; GET returns a value', pxBefore !== null, JSON.stringify(pxBeforeResp)); @@ -246,7 +335,7 @@ function main(): void { // ── SET/GET round-trip on the reference oscillator (XF, calibration), // restoring the original. ±5 Hz is far below any usable calibration, so // the probe never meaningfully detunes the radio even if restore failed. ── - const xfBeforeResp = send(fd, 'XF;'); + const xfBeforeResp = sendGet(fd, 'XF;', 'XF'); const xfBefore = parseIntField(xfBeforeResp, 'XF'); record('XF; GET returns a plausible ref frequency', xfBefore !== null && xfBefore > 14_000_000 && xfBefore < 28_000_000, JSON.stringify(xfBeforeResp)); @@ -264,16 +353,19 @@ function main(): void { // ── FD; factory-defaults frame — one 11-value CSV frame. NOTE: SR2; // (factory reset) is deliberately NOT exercised here — it wipes band // memories and calibration on every run. Test it manually from the UI. ── - const fdResp = send(fd, 'FD;'); - record('FD; returns an 11-value factory-defaults frame', /^FD-?\d+(,-?\d+){10};$/.test(fdResp), JSON.stringify(fdResp)); + const fdResp = sendGet(fd, 'FD;', 'FD'); + record('FD; returns an 11-value factory-defaults frame', /FD-?\d+(,-?\d+){10};/.test(fdResp), JSON.stringify(fdResp)); // ── Unknown command should not desync the parser (firmware replies "?;") ── const unknownResp = send(fd, 'ZZ;'); record('Unknown command gets a reply (does not hang)', unknownResp.length > 0, JSON.stringify(unknownResp)); // ── SR soft-restart — deliberately the LAST check: the radio watchdog- - // reboots and is off the wire for a few seconds afterwards. ── - const srResp = send(fd, 'SR;'); + // reboots and is off the wire for a few seconds afterwards. Resending SR; + // if the ack is dropped (see sendGet comment) is safe: the radio either + // hasn't rebooted yet (a second trigger is harmless) or is already + // resetting (the second SR; is simply ignored while it comes back up). ── + const srResp = sendGet(fd, 'SR;', 'SR1;'); record('SR; acks with SR1; before rebooting', srResp.includes('SR1;'), JSON.stringify(srResp)); if (srResp.includes('SR1;')) { @@ -281,7 +373,7 @@ function main(): void { const faResp = send(fd, 'FA;', 600); record( 'Radio responsive again after SR restart', - /^FA\d{11};$/.test(faResp), + /FA\d{11};/.test(faResp), `boot=${JSON.stringify(bootNoise)} fa=${JSON.stringify(faResp)}`, ); } diff --git a/src/components/RadioCATPanel.tsx b/src/components/RadioCATPanel.tsx index bf2d064..65c903e 100644 --- a/src/components/RadioCATPanel.tsx +++ b/src/components/RadioCATPanel.tsx @@ -22,7 +22,7 @@ interface RadioPreset { } const RADIO_PRESETS: RadioPreset[] = [ - { label: 'uSDX BLACK_BRICK 4.00h', baudRate: 38400, dataBits: 8, stopBits: 1, parity: 'none', notes: 'PU7FTW custom firmware — adds volume, attenuator, noise reduction, AGC, filter, TX drive, backlight, PA bias and S-meter controls, batched CAT polling', rigProfile: 'usdx-blackbrick' }, + { label: 'uSDX BLACK_BRICK 4.01a', baudRate: 38400, dataBits: 8, stopBits: 1, parity: 'none', notes: 'PU7FTW custom firmware — adds volume, attenuator, noise reduction, AGC, filter, TX drive, backlight, PA bias and S-meter controls, batched CAT polling', rigProfile: 'usdx-blackbrick' }, { label: 'Kenwood TS-480 / TS-590 / TS-2000', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', notes: 'Default 9600 8N1', rigProfile: 'generic' }, { label: 'Kenwood TS-480 (high speed)', baudRate: 57600, dataBits: 8, stopBits: 1, parity: 'none', notes: 'Configure in menu 60', rigProfile: 'generic' }, { label: 'Icom IC-7300 / IC-7610', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', notes: 'Set CI-V USB Baud Rate to 9600', rigProfile: 'generic' }, @@ -392,12 +392,15 @@ const DIGITAL_ATTENUATOR_DB_LABELS = [ // (this rig runs at 20MHz), param enum FILTER, EEPROM 0x13. const FILTER_LABELS = ['Full', '3kHz', '2.4kHz', '1.8kHz', '500Hz', '200Hz', '100Hz', '50Hz']; -// AGC firmware behavior note: FAST_AGC is undefined in this build (usdxBLACKBRICK.ino), -// so the runtime only branches on agc==1 vs agc==0 (process_agc_fast vs no AGC) — agc==2 -// ("Slow") has no distinct code path and is indistinguishable from off. The menu itself -// (case AGC, offon_label) exposes only OFF/ON. So the UI mirrors that as a toggle. +// AGC firmware behavior note: since the 2026-07-06 firmware the radio has a single +// AGC algorithm (M0PUB fast-attack/slow-decay, ~60dB range) as a plain OFF/ON toggle — +// the old FAST_AGC Fast/Slow tri-state is gone and AG0 SET rejects values above 1. +// The AGC target level is a separate 1..14 setting (AL command, default 4): output +// peaks are held between level*256 and level*384, so higher = louder before clamping. const AGC_ON = 1; const AGC_OFF = 0; +const AGC_LEVEL_MIN = 1; +const AGC_LEVEL_MAX = 14; // ── SMeterDisplay ───────────────────────────────────────────────────────────── // Read-only dBm readout — no +/- controls, since there is no SM SET command. @@ -492,18 +495,19 @@ function FactoryResetButton({ onConfirm }: { onConfirm: () => void }) { } // ── BlackBrickControls ──────────────────────────────────────────────────────── -// uSDX BLACK_BRICK 4.00h custom extension controls: volume, attenuators, noise +// uSDX BLACK_BRICK 4.01a custom extension controls: volume, attenuators, noise // reduction, AGC, filter, TX drive, backlight. Wraps onto its own row below the // main toolbar. S-Meter is shown separately in the main toolbar since it's a // read-only reading, not a control. PA bias lives in its own on-demand panel // (PABiasPanel) behind the wrench button — not polled, queried when opened. -function BlackBrickControls({ volume, att1, att2, nr, agc, filter, drive, backlight, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onFilter, onDrive, onBacklight, onTogglePA, onReset }: { +function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, drive, backlight, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onAgcLevel, onFilter, onDrive, onBacklight, onTogglePA, onReset }: { volume: number | null; att1: number | null; att2: number | null; nr: number | null; agc: number | null; + agcLevel: number | null; filter: number | null; drive: number | null; backlight: number | null; @@ -513,6 +517,7 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, filter, drive, backli onAtt2: (n: number) => void; onNR: (n: number) => void; onAGC: (n: number) => void; + onAgcLevel: (n: number) => void; onFilter: (n: number) => void; onDrive: (n: number) => void; onBacklight: (n: number) => void; @@ -544,6 +549,11 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, filter, drive, backli + {/* AGC target level (AL command) — only meaningful while AGC is on */} + {agcOn && ( + + )} +
@@ -739,8 +749,8 @@ const DEFAULT_CONFIG: CATConnectionConfig & { presetIdx: number } = { }; export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnType; collapsed?: boolean }) { - const { state, connect, disconnect, setFrequency, setMode, setPTT, setVolume, setAtt1, setAtt2, setNR, setAGC, setFilter, setDrive, setBacklight, getPABias, setPABias, resetRadio, getFactoryDefaults, factoryResetRadio } = cat; - const { connected, frequency, mode, ptt, error, isSupported, volume, att1, att2, nr, agc, filter, sMeter, drive, backlight } = state; + const { state, connect, disconnect, setFrequency, setMode, setPTT, setVolume, setAtt1, setAtt2, setNR, setAGC, setAgcLevel, setFilter, setDrive, setBacklight, getPABias, setPABias, resetRadio, getFactoryDefaults, factoryResetRadio } = cat; + const { connected, frequency, mode, ptt, error, isSupported, volume, att1, att2, nr, agc, agcLevel, filter, sMeter, drive, backlight } = state; const [showSettings, setShowSettings] = useState(false); const [showPABias, setShowPABias] = useState(false); @@ -756,6 +766,7 @@ export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnT const handleAtt2 = useCallback((n: number) => { setAtt2(n).catch(() => {}); }, [setAtt2]); const handleNR = useCallback((n: number) => { setNR(n).catch(() => {}); }, [setNR]); const handleAGC = useCallback((n: number) => { setAGC(n).catch(() => {}); }, [setAGC]); + const handleAgcLevel = useCallback((n: number) => { setAgcLevel(n).catch(() => {}); }, [setAgcLevel]); const handleFilter = useCallback((n: number) => { setFilter(n).catch(() => {}); }, [setFilter]); const handleDrive = useCallback((n: number) => { setDrive(n).catch(() => {}); }, [setDrive]); const handleBacklight = useCallback((n: number) => { setBacklight(n).catch(() => {}); }, [setBacklight]); @@ -835,15 +846,15 @@ export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnT {/* PTT */} - {/* uSDX BLACK_BRICK 4.00h extensions */} + {/* uSDX BLACK_BRICK 4.01a extensions */} {config.rigProfile === 'usdx-blackbrick' && !collapsed && ( diff --git a/src/hooks/useRadioCAT.ts b/src/hooks/useRadioCAT.ts index 4cb2886..9324005 100644 --- a/src/hooks/useRadioCAT.ts +++ b/src/hooks/useRadioCAT.ts @@ -50,15 +50,19 @@ export interface RadioState { error: string | null; /** false on SSR, true once mounted in a supporting browser */ isSupported: boolean; - /** uSDX BLACK_BRICK 4.00h extension state — null unless rigProfile is 'usdx-blackbrick' */ + /** uSDX BLACK_BRICK 4.01a extension state — null unless rigProfile is 'usdx-blackbrick' */ volume: number | null; att1: number | null; att2: number | null; nr: number | null; - /** AGC state: 0=OFF, 1=ON. The firmware CAT command allows values up to 2 - * ("Slow"), but this build has FAST_AGC undefined, so only agc==1 has a - * distinct code path — 2 behaves identically to 0. Treated as a toggle. */ + /** AGC state: 0=OFF, 1=ON (single algorithm: M0PUB fast-attack/slow-decay). + * Since firmware 2026-07-06 the old Fast/Slow tri-state is gone and the CAT + * command rejects values above 1 (echo returns the old value). */ agc: number | null; + /** AGC target level, 1..14 (AL command). Output peaks are held between + * level*256 and level*384; default 4 = the original 1024..1536 window. + * Higher = louder audio before the AGC clamps. */ + agcLevel: number | null; /** Filter bandwidth index: 0=Full, 1=3000Hz, 2=2400Hz, 3=1800Hz, 4=500Hz, 5=200Hz, 6=100Hz, 7=50Hz */ filter: number | null; /** S-meter reading in dBm. Read-only — there is no corresponding setter. */ @@ -105,6 +109,7 @@ export interface RadioCATControls { setAtt2: (n: number) => Promise; setNR: (n: number) => Promise; setAGC: (n: number) => Promise; + setAgcLevel: (n: number) => Promise; setFilter: (n: number) => Promise; setDrive: (n: number) => Promise; setBacklight: (n: number) => Promise; @@ -143,7 +148,7 @@ export interface RadioCATControls { // PTT on: TX; (no echo) // PTT off: RX; (no echo) -// ── uSDX BLACK_BRICK 4.00h — PU7FTW custom extensions ──────────────────────── +// ── uSDX BLACK_BRICK 4.01a — PU7FTW custom extensions ──────────────────────── // Not part of the TS-480 spec. All SET commands echo the new value as a GET // reply, and are safe to include in a multi-command string (e.g. "VO;AT;A2;"). // Query volume: VO; → VOn; (-1..16, -1 = mute) @@ -154,10 +159,13 @@ export interface RadioCATControls { // Set ATT2: A2n; → A2n; // Query NR: NR; → NRn; (0..8, 0 = off) // Set NR: NRn; → NRn; -// Query AGC: AG0; → AG0n; (0=OFF, 1=ON — firmware accepts up to 2, -// but this build has FAST_AGC undefined so -// 2 ["Slow"] is not a distinct state from 0) +// Query AGC: AG0; → AG0n; (0=OFF, 1=ON — single M0PUB algorithm since +// firmware 2026-07-06; SET rejects n>1 and +// echoes the old value) // Set AGC: AG0n; → AG0n; (n in 0..1) +// Query AGC level: AL; → ALn; (1..14 — AGC target window: peaks held in +// Set AGC level: ALn; → ALn; [n*256..n*384], default 4; out-of-range +// SETs are ignored, echo returns old value) // Query filter: FW; → FWn; (0=Full 1=3000 2=2400 3=1800 4=500 5=200 6=100 7=50 Hz) // Set filter: FWn; → FWn; (n in 0..7) // Query S-meter: SM; → SMn; (signed dBm, read-only — there is no SM SET) @@ -194,9 +202,9 @@ export interface RadioCATControls { // PA PWM LUT, so they're fetched/set on demand // from the PA settings panel only.) // The firmware supports serialized/batched queries in one write, e.g. -// "FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;" — replies come back concatenated in the same order. +// "FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;" — replies come back concatenated in the same order. -const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;']; +const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;']; const KENWOOD_MODE_MAP: Record = { '1': 'LSB', '2': 'USB', '3': 'CW', '4': 'FM', '5': 'AM', '6': 'RTTY', @@ -233,7 +241,7 @@ export function useRadioCAT(): RadioCATControls { connected: false, frequency: null, mode: null, ptt: false, error: null, isSupported: false, volume: null, att1: null, att2: null, nr: null, - agc: null, filter: null, sMeter: null, drive: null, + agc: null, agcLevel: null, filter: null, sMeter: null, drive: null, backlight: null, }); @@ -265,8 +273,8 @@ export function useRadioCAT(): RadioCATControls { // a clean teardown when the port disappears out from under us. const disconnectRef = useRef<() => void>(() => {}); - const lastSetRef = useRef<{ frequency: number; mode: number; volume: number; att1: number; att2: number; nr: number; agc: number; filter: number; drive: number; backlight: number }>({ - frequency: 0, mode: 0, volume: 0, att1: 0, att2: 0, nr: 0, agc: 0, filter: 0, drive: 0, backlight: 0, + const lastSetRef = useRef<{ frequency: number; mode: number; volume: number; att1: number; att2: number; nr: number; agc: number; agcLevel: number; filter: number; drive: number; backlight: number }>({ + frequency: 0, mode: 0, volume: 0, att1: 0, att2: 0, nr: 0, agc: 0, agcLevel: 0, filter: 0, drive: 0, backlight: 0, }); const log = useCallback((level: 'debug' | 'info' | 'warn' | 'error', ...args: unknown[]) => { @@ -517,8 +525,9 @@ export function useRadioCAT(): RadioCATControls { const sMeter = byPrefix.has('SM') ? parseIntField(byPrefix.get('SM')!, 'SM') : null; const drive = byPrefix.has('DR') ? parseIntField(byPrefix.get('DR')!, 'DR') : null; const backlight = byPrefix.has('BL') ? parseIntField(byPrefix.get('BL')!, 'BL') : null; + const agcLevel = byPrefix.has('AL') ? parseIntField(byPrefix.get('AL')!, 'AL') : null; - log('debug', 'poll(batch) — freq:', freq, 'mode:', mode, 'agc:', agc, 'filt:', filter, + log('debug', 'poll(batch) — freq:', freq, 'mode:', mode, 'agc:', agc, 'agcLvl:', agcLevel, 'filt:', filter, 'vol:', volume, 'att1:', att1, 'att2:', att2, 'nr:', nr, 'sm:', sMeter, 'drive:', drive, 'bl:', backlight, `[q:${queueRef.current.length}]`); setState(prev => ({ @@ -526,6 +535,7 @@ export function useRadioCAT(): RadioCATControls { frequency: freq !== null && (now - ls.frequency > SET_GRACE_MS) ? freq : prev.frequency, mode: mode !== null && (now - ls.mode > SET_GRACE_MS) ? mode : prev.mode, agc: agc !== null && (now - ls.agc > SET_GRACE_MS) ? agc : prev.agc, + agcLevel: agcLevel !== null && (now - ls.agcLevel > SET_GRACE_MS) ? agcLevel : prev.agcLevel, filter: filter !== null && (now - ls.filter > SET_GRACE_MS) ? filter : prev.filter, volume: volume !== null && (now - ls.volume > SET_GRACE_MS) ? volume : prev.volume, att1: att1 !== null && (now - ls.att1 > SET_GRACE_MS) ? att1 : prev.att1, @@ -588,7 +598,7 @@ export function useRadioCAT(): RadioCATControls { rxBufRef.current = ''; setState(prev => ({ ...prev, connected: false, frequency: null, mode: null, ptt: false, error: null, - volume: null, att1: null, att2: null, nr: null, agc: null, filter: null, sMeter: null, drive: null, + volume: null, att1: null, att2: null, nr: null, agc: null, agcLevel: null, filter: null, sMeter: null, drive: null, backlight: null, })); }, [log]); @@ -698,6 +708,13 @@ export function useRadioCAT(): RadioCATControls { await write(`AG0${n};`); }, [log, write]); + const setAgcLevel = useCallback(async (n: number) => { + lastSetRef.current.agcLevel = Date.now(); + log('info', 'setAgcLevel →', n); + setState(prev => ({ ...prev, agcLevel: n })); + await write(`AL${n};`); + }, [log, write]); + const setFilter = useCallback(async (n: number) => { lastSetRef.current.filter = Date.now(); log('info', 'setFilter →', n); @@ -789,7 +806,7 @@ export function useRadioCAT(): RadioCATControls { return { state, connect, disconnect, setFrequency, setMode, setPTT, - setVolume, setAtt1, setAtt2, setNR, setAGC, setFilter, setDrive, + setVolume, setAtt1, setAtt2, setNR, setAGC, setAgcLevel, setFilter, setDrive, setBacklight, getPABias, setPABias, resetRadio, getFactoryDefaults, factoryResetRadio, getRefFreq, setRefFreq, }; diff --git a/src/lib/cat/__tests__/protocol.test.ts b/src/lib/cat/__tests__/protocol.test.ts index 8e70d1d..78d4d65 100644 --- a/src/lib/cat/__tests__/protocol.test.ts +++ b/src/lib/cat/__tests__/protocol.test.ts @@ -1,9 +1,9 @@ /** - * CAT protocol unit tests — uSDX BLACK_BRICK 4.00h / TS-480 Kenwood dialect. + * CAT protocol unit tests — uSDX BLACK_BRICK 4.01a / TS-480 Kenwood dialect. * * These tests are pure JS: no hardware, no serial port, no browser APIs. * They validate command string construction, response parsing, multi-command - * batching, and all custom PU7FTW extension commands (BL/VO/TQ/AT/A2/NR/AG0/FW/SM/DR/PM/PX). + * batching, and all custom PU7FTW extension commands (BL/VO/TQ/AT/A2/NR/AG0/AL/FW/SM/DR/PM/PX). * Note: BL (backlight) is polled and surfaced in the UI again since the * 2026-07-04 firmware fix (BACKLIGHT_PIN moved to the correct pin, PD3). * PM/PX (PA bias endpoints) are deliberately NOT polled — they're fetched @@ -106,14 +106,19 @@ describe('command construction', () => { expect(`VO0;`).toMatch(/^VO-?\d+;$/); }); - test('AG0 set — wire format accepts 0..2, though this build only uses 0/1', () => { - // Firmware Command_AG0_SET allows v<=2, but FAST_AGC is undefined in this - // build, so agc==2 has no distinct runtime code path from agc==0 (see - // usdxBLACKBRICK.ino ~line 3305-3318). The UI only ever sends 0 or 1. + test('AG0 set — firmware accepts only 0/1 since the single-algorithm AGC change', () => { + // Since the 2026-07-06 firmware, Command_AG0_SET rejects v>1 (echo returns + // the old value). The old FAST_AGC Fast/Slow tri-state is gone: agc==1 is + // the single M0PUB fast-attack/slow-decay algorithm. expect(`AG00;`).toBe('AG00;'); expect(`AG01;`).toBe('AG01;'); }); + test('AL set — AGC level, 1..14', () => { + expect('AL4;').toMatch(/^AL\d{1,2};$/); + expect('AL14;').toMatch(/^AL\d{1,2};$/); + }); + test('FW set — 0..7', () => { for (let i = 0; i <= 7; i++) { expect(`FW${i};`).toMatch(/^FW\d;$/); @@ -195,12 +200,19 @@ describe('response parsing — custom BLACK_BRICK commands', () => { expect(parseIntField('NR8;', 'NR')).toBe(8); }); - test('AG0 — AGC: 0=OFF 1=ON (this build has FAST_AGC undefined, so the UI toggle only uses 0/1)', () => { + test('AG0 — AGC: 0=OFF 1=ON (single M0PUB algorithm; SET rejects values above 1)', () => { expect(parseIntField('AG00;', 'AG0')).toBe(0); expect(parseIntField('AG01;', 'AG0')).toBe(1); expect(parseIntField('AG0;', 'AG0')).toBeNull(); }); + test('AL — AGC target level 1..14 (default 4; peaks held in [lvl*256..lvl*384])', () => { + expect(parseIntField('AL4;', 'AL')).toBe(4); // firmware default + expect(parseIntField('AL1;', 'AL')).toBe(1); + expect(parseIntField('AL14;', 'AL')).toBe(14); + expect(parseIntField('AL;', 'AL')).toBeNull(); + }); + test('FW — filter index 0..7', () => { expect(parseIntField('FW0;', 'FW')).toBe(0); // Full expect(parseIntField('FW4;', 'FW')).toBe(4); // 500 Hz @@ -230,7 +242,7 @@ describe('response parsing — custom BLACK_BRICK commands', () => { test('PM/PX — PA bias endpoints (on-demand, not polled)', () => { expect(parseIntField('PM10;', 'PM')).toBe(10); // firmware default (bias min) expect(parseIntField('PM0;', 'PM')).toBe(0); - expect(parseIntField('PX160;', 'PX')).toBe(160); // firmware default (PA max) + expect(parseIntField('PX130;', 'PX')).toBe(130); // firmware default (PA bias max, 130 since 4.01a) expect(parseIntField('PX255;', 'PX')).toBe(255); expect(parseIntField('PM;', 'PM')).toBeNull(); expect(parseIntField('PX;', 'PX')).toBeNull(); @@ -259,21 +271,21 @@ describe('response parsing — custom BLACK_BRICK commands', () => { return m ? m[1].split(',').map(Number) : null; }; // Firmware 4.00g defaults: vol,att,att2,nr,agc,filt,drive,backlight,pwm_min,pwm_max,md - const v = parse('FD11,0,0,0,1,0,4,1,10,160,2;'); - expect(v).toEqual([11, 0, 0, 0, 1, 0, 4, 1, 10, 160, 2]); + const v = parse('FD11,0,0,0,1,0,4,1,10,130,2;'); + expect(v).toEqual([11, 0, 0, 0, 1, 0, 4, 1, 10, 130, 2]); expect(parse('FD11,0,0;')).toBeNull(); // wrong arity expect(parse('FD;')).toBeNull(); // volume can be negative (-1 = mute) - expect(parse('FD-1,0,0,0,1,0,4,1,10,160,2;')![0]).toBe(-1); + expect(parse('FD-1,0,0,0,1,0,4,1,10,130,2;')![0]).toBe(-1); }); }); describe('multi-command / batched poll parsing', () => { - const BATCH_RESPONSE = 'FA00014225000;MD2;AG01;FW3;VO8;AT2;A216;NR4;SM-68;DR5;BL1;'; + const BATCH_RESPONSE = 'FA00014225000;MD2;AG01;FW3;VO8;AT2;A216;NR4;SM-68;DR5;BL1;AL4;'; - test('splitFrames — all 11 frames', () => { + test('splitFrames — all 12 frames', () => { const frames = splitFrames(BATCH_RESPONSE); - expect(frames).toHaveLength(11); + expect(frames).toHaveLength(12); expect(frames[0]).toBe('FA00014225000;'); expect(frames[1]).toBe('MD2;'); expect(frames[2]).toBe('AG01;'); @@ -365,10 +377,10 @@ describe('IF frame parsing', () => { }); describe('BLACKBRICK_POLL_CMDS array', () => { - const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;']; + const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;']; - test('11 commands in poll array', () => { - expect(BLACKBRICK_POLL_CMDS).toHaveLength(11); + test('12 commands in poll array', () => { + expect(BLACKBRICK_POLL_CMDS).toHaveLength(12); }); test('AG0; is included', () => { @@ -398,7 +410,7 @@ describe('BLACKBRICK_POLL_CMDS array', () => { test('prefixes derived from commands', () => { const prefixes = BLACKBRICK_POLL_CMDS.map(c => c.substring(0, 2)); - expect(prefixes).toEqual(['FA', 'MD', 'AG', 'FW', 'VO', 'AT', 'A2', 'NR', 'SM', 'DR', 'BL']); + expect(prefixes).toEqual(['FA', 'MD', 'AG', 'FW', 'VO', 'AT', 'A2', 'NR', 'SM', 'DR', 'BL', 'AL']); }); }); @@ -468,7 +480,7 @@ describe('range validation', () => { // Mirrors Command_PM_SET (v < pwm_max) and Command_PX_SET (v >= pwm_min && v <= 255). const validMin = (v: number, max: number) => v >= 0 && v < max; const validMax = (v: number, min: number) => v >= min && v <= 255; - expect(validMin(10, 160)).toBe(true); // firmware defaults + expect(validMin(10, 130)).toBe(true); // firmware defaults expect(validMin(160, 160)).toBe(false); // min must stay below max expect(validMax(160, 10)).toBe(true); expect(validMax(255, 10)).toBe(true); diff --git a/src/lib/cat/mockSerial.ts b/src/lib/cat/mockSerial.ts index 7ed4f82..f6b09d1 100644 --- a/src/lib/cat/mockSerial.ts +++ b/src/lib/cat/mockSerial.ts @@ -46,10 +46,10 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { const rig = { frequency: opts.initialFrequency ?? 7074000, modeDigit: opts.modeDigit ?? 2, // USB - agc: 1, filter: 1, volume: 10, att1: 0, att2: 0, nr: 0, drive: 4, + agc: 1, agcLevel: 4, filter: 1, volume: 10, att1: 0, att2: 0, nr: 0, drive: 4, sMeter: 7, backlight: 1, - pwmMin: 10, pwmMax: 160, // PA bias endpoints (firmware defaults) + pwmMin: 10, pwmMax: 130, // PA bias endpoints (firmware defaults) fxtal: 25_000_000, // reference oscillator (XF, menu "Ref frq") tx: false, }; @@ -69,6 +69,15 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { let rxBuffer = ''; function respond(cmd: string): string { + // AGC level SET — handled before the generic set path because single-digit + // sets ("AL5") are only 3 chars and the generic path requires length > 3. + // Validated echo like the firmware: 1..14, out-of-range keeps the old value. + const alSet = cmd.match(/^AL(\d+)$/); + if (alSet) { + const v = parseInt(alSet[1], 10); + if (v >= 1 && v <= 14) rig.agcLevel = v; + return `AL${rig.agcLevel};`; + } // set-commands carry a payload and (like the real firmware) get no reply const set = cmd.match(/^([A-Z]{2})([0-9-]+.*)$/); if (set && cmd.length > 3) { @@ -93,6 +102,12 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { if (v >= rig.pwmMin && v <= 255) rig.pwmMax = v; return `PX${rig.pwmMax};`; } + // AGC on/off — firmware accepts only 0/1 since the single-algorithm change + if (prefix === 'AG') { + const v = parseInt(val.slice(1), 10); // "AG0n" — val is "0n" + if (v <= 1) rig.agc = v; + return ''; + } // Reference oscillator (calibration) — validated echo like the firmware. if (prefix === 'XF') { const v = parseInt(val, 10); @@ -116,6 +131,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { return `SM${rig.sMeter};`; } case 'DR': return `DR${rig.drive};`; + case 'AL': return `AL${rig.agcLevel};`; case 'BL': return `BL${rig.backlight};`; case 'PM': return `PM${rig.pwmMin};`; case 'PX': return `PX${rig.pwmMax};`; @@ -126,13 +142,13 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { // Factory reset: acks SR2; and restores the firmware defaults (the real // radio also reboots — same "no simulated outage" caveat as SR). case 'SR2': - rig.modeDigit = 2; rig.agc = 1; rig.filter = 0; rig.volume = 11; + rig.modeDigit = 2; rig.agc = 1; rig.agcLevel = 4; rig.filter = 0; rig.volume = 11; rig.att1 = 0; rig.att2 = 0; rig.nr = 0; rig.drive = 4; - rig.backlight = 1; rig.pwmMin = 10; rig.pwmMax = 160; + rig.backlight = 1; rig.pwmMin = 10; rig.pwmMax = 130; return 'SR2;'; // Factory-default values as one FD frame — mirrors the firmware's // compile-time initializers (vol,att,att2,nr,agc,filt,drive,bl,pm,px,md). - case 'FD': return 'FD11,0,0,0,1,0,4,1,10,160,2;'; + case 'FD': return 'FD11,0,0,0,1,0,4,1,10,130,2;'; case 'XF': return `XF${rig.fxtal};`; case 'TX': rig.tx = true; return ''; case 'RX': rig.tx = false; return ''; diff --git a/src/lib/ft/__tests__/parser.test.ts b/src/lib/ft/__tests__/parser.test.ts index ee63ee4..eba9976 100644 --- a/src/lib/ft/__tests__/parser.test.ts +++ b/src/lib/ft/__tests__/parser.test.ts @@ -1,5 +1,6 @@ import { parseFTMsg, mergeContacts, isValidCallsign, gridToLatLon, haversineKm, + generateADIF, isConfirmedQSO, } from '../parser'; import { callsignCountry } from '../prefixes'; @@ -110,6 +111,44 @@ describe('mergeContacts', () => { }); }); +describe('generateADIF', () => { + const t = new Date('2026-06-12T12:00:00Z'); + // Standard WSJT-X exchange: THEM calls CQ, ME answers and completes the QSO. + const me = 'K1ABC'; + const them = 'W9XYZ'; + const qsoMsgs = [ + `CQ ${them} FN42`, + `${them} ${me} FN31`, + `${me} ${them} -10`, + `${them} ${me} R-05`, + `${me} ${them} RR73`, + ]; + const merge = (msgs: string[]) => + mergeContacts(new Map(), t, msgs.map(msg => ({ msg, freq: 1500, snr: -10 })), 0); + + it('never emits a record for our own callsign (the self-QSO bug)', () => { + const contacts = merge(qsoMsgs); + // mergeContacts tracks every caller/callee it sees, including us — + // confirm the self-entry exists so this test actually exercises the guard. + expect(contacts.has(me)).toBe(true); + + const adif = generateADIF(contacts, 'FT8' as any, { myCall: me, myGrid: 'FN31' }); + expect(adif).not.toContain(`${me}`); + }); + + it('records CALL as the other station and STATION_CALLSIGN as ours', () => { + const contacts = merge(qsoMsgs); + const adif = generateADIF(contacts, 'FT8' as any, { myCall: me, myGrid: 'FN31' }); + expect(adif).toContain(`${them}`); + expect(adif).toContain(`${me}`); + }); + + it('marks a completed exchange with the peer as a confirmed QSO', () => { + const contacts = merge(qsoMsgs); + expect(isConfirmedQSO(contacts.get(them)!, me)).toBe(true); + }); +}); + describe('callsignCountry', () => { it('resolves countries from callsign prefixes', () => { expect(callsignCountry('LX1TI')?.countryCode).toBe('LU'); diff --git a/src/lib/ft/parser.ts b/src/lib/ft/parser.ts index 5de1156..91fcb11 100644 --- a/src/lib/ft/parser.ts +++ b/src/lib/ft/parser.ts @@ -430,12 +430,15 @@ function segmentQSOs(msgs: ContactMsg[]): ContactMsg[][] { // - I sent them a report (I reported their signal). // This covers all standard FT8 QSO flows regardless of who called CQ. // Without myCall we cannot determine participation, so all contacts pass through. +// NOTE: `msgs` belongs to the PEER's contact record (c), not mine — role='tx' +// means the peer transmitted, role='rx' means the peer was addressed (i.e. I +// transmitted to them). function segmentIsConfirmed(msgs: ContactMsg[], me: string): boolean { - // Messages I transmitted (I am the caller, role='tx' on my contact entry) - const iSent = msgs.filter(m => m.role === 'tx' && m.parsed.caller?.toUpperCase() === me); - // Messages they transmitted to me (they are the caller, callee=me, role='tx' on their entry - // but stored as role='rx' on my contact because I was addressed) - const theySent = msgs.filter(m => m.role === 'rx' && m.parsed.callee?.toUpperCase() === me); + // Messages they transmitted (peer is caller, role='tx' on the peer's contact entry) + const theySent = msgs.filter(m => m.role === 'tx' && m.parsed.callee?.toUpperCase() === me); + // Messages I transmitted to them (peer is callee, role='rx' on the peer's contact entry + // because the peer was addressed while I was the caller) + const iSent = msgs.filter(m => m.role === 'rx' && m.parsed.caller?.toUpperCase() === me); if (iSent.length === 0 || theySent.length === 0) return false; const iSentReport = iSent.some(m => REPORT_TYPES.has(m.parsed.type)); const theySentReport = theySent.some(m => REPORT_TYPES.has(m.parsed.type)); @@ -473,12 +476,19 @@ export function generateADIF( const me = (myCall ?? '').toUpperCase(); for (const c of contacts.values()) { + // Skip the entry keyed by our own callsign — mergeContacts tracks every + // caller/callee it sees, including us, so a "contact" for myCall is not a + // real QSO partner. Without this guard a completed exchange produces an + // ADIF record with CALL === STATION_CALLSIGN (a bogus self-worked QSO). + if (me && c.callsign.toUpperCase() === me) continue; // Each confirmed QSO segment with this callsign becomes a separate ADIF record. + // NOTE: `seg` messages belong to the PEER's contact record (c) — role='tx' + // means the peer transmitted, role='rx' means the peer was addressed (I transmitted). for (const seg of confirmedSegments(c, me)) { - // Messages I transmitted: role='tx' on my contact, I am the caller - const iSentMsgs = seg.filter(m => m.role === 'tx' && m.parsed.caller?.toUpperCase() === me); - // Messages they transmitted to me: role='rx' on my contact (I was addressed), they are caller - const theySentMsgs = seg.filter(m => m.role === 'rx' && m.parsed.callee?.toUpperCase() === me); + // Messages I transmitted to them: role='rx' on their contact (they were addressed), I am the caller + const iSentMsgs = seg.filter(m => m.role === 'rx' && m.parsed.caller?.toUpperCase() === me); + // Messages they transmitted to me: role='tx' on their contact (they are caller), callee=me + const theySentMsgs = seg.filter(m => m.role === 'tx' && m.parsed.callee?.toUpperCase() === me); // RST_RCVD = best SNR on signals I received from them (their tx, stored as my rx) const bestSnrRcvd = theySentMsgs.reduce((best, m) => m.snr > best ? m.snr : best, -99); From dc7079212b4c9642db5923b5fb8dd7d879a2895e Mon Sep 17 00:00:00 2001 From: Igor Nogueira de Oliveira Date: Mon, 6 Jul 2026 22:35:31 -0300 Subject: [PATCH 2/6] Firmware 4.01b: TX time-out guardrail, CAT ticking-noise fix, hidden menu fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TX time-out timer (TOT): force-unkeys the PA after a configurable limit (menu 3.4, TT CAT command, UI stepper; 0-255s, default 180, 0=off); covers both CAT TX and the blocking physical-PTT path. - Throttle LCD S-meter redraws to 1/s and only-on-change while CAT is active — the un-frozen S-meter was re-triggering the shared LCD/UART pins' audible noise path on every redraw. - Fix N_PARAMS not updated for the AGC_LVL enum insertion, which created a blank hidden menu slot and dropped the Backlight item. - Web: TX Timeout stepper, 13-command poll batch, mock and tests updated (466 passing, 50 hardware checks). --- firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino | 57 ++++++++++++++++++++-- scripts/cat-hardware-test.ts | 19 +++++++- src/components/RadioCATPanel.tsx | 15 ++++-- src/hooks/useRadioCAT.ts | 31 +++++++++--- src/lib/cat/__tests__/protocol.test.ts | 27 +++++++--- src/lib/cat/mockSerial.ts | 11 ++++- 6 files changed, 134 insertions(+), 26 deletions(-) diff --git a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino index 3e906b0..d31c20a 100644 --- a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino +++ b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino @@ -4246,6 +4246,20 @@ int16_t smeter(int16_t ref = 0) if (!smode) return dbm; + // While a CAT session is live, every LCD write is audible in the RX audio + // (shared UART/LCD pins — pre()/post() mask the serial port per nibble, + // "NOISE LEAK INTO RX!!!"). The pre-2026-07-06 firmware avoided that by + // disabling the S-meter display outright on CAT connect — which froze the + // LCD value (the bug we fixed). Compromise: while cat_active, redraw at + // most once per second and only when the reading actually changed. Set + // menu S-Meter to OFF for a fully silent display. + if (cat_active) { + static int16_t dbm_drawn = INT16_MAX; + static uint32_t next_draw = 0; + if ((dbm == dbm_drawn) || (millis() < next_draw)) return dbm; + dbm_drawn = dbm; next_draw = millis() + 1000; + } + lcd.noCursor(); if (smode == 1) { // dBm meter lcd.setCursor(9, 0); lcd.print((int16_t)dbm); lcd.print(F("dBm ")); @@ -4339,6 +4353,12 @@ int16_t _centiGain = 0; uint8_t txdelay = 0; uint32_t semi_qsk_timeout = 0; +// TX time-out timer (TOT): hard guardrail against a stuck PTT overheating the PA. +// tot = limit in seconds (0 = disabled); armed by switch_rxtx(1), checked from +// loop() (CAT TX) and inside the physical-PTT hold loop (which blocks loop()). +volatile uint8_t tot = 180; +volatile uint32_t tx_timeout_at = 0; + // Set RX or TX mode, with RIT support and CW offset void switch_rxtx(uint8_t tx_enable) { @@ -4396,6 +4416,7 @@ void switch_rxtx(uint8_t tx_enable) if (tx_enable) { // TX + tx_timeout_at = (tot) ? millis() + 1000UL * tot : 0; // arm the TX time-out guard _centiGain = centiGain; // backup AGC setting #ifdef SEMI_QSK semi_qsk_timeout = 0; @@ -4418,6 +4439,7 @@ void switch_rxtx(uint8_t tx_enable) else { // RX + tx_timeout_at = 0; // disarm the TX time-out guard if ((mode == CW) && (!(semi_qsk_timeout))) { #ifdef SEMI_QSK #ifdef KEYER @@ -4869,13 +4891,13 @@ const char* keyer_mode_label[] = { "IambicA", "IambicB","Straight" }; // GW8RDI #define _N(a) sizeof(a)/sizeof(a[0]) -#define N_PARAMS 44 // last visible menu param enum value (BACKL=44); update when adding/removing menu items +#define N_PARAMS 46 // last visible menu param enum value (BACKL=46 after the AGC_LVL and TOT enum insertions); update when adding/removing enum entries BEFORE BACKL — getting this wrong makes a blank "hidden" menu slot appear at the wrap and drops the Backlight item #ifdef KEEP_BAND_DATA #define I_PARAMS 5+9 -enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, AGC_LVL, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, BAND_DATA0, BAND_DATA1, BAND_DATA2, BAND_DATA3, BAND_DATA4, BAND_DATA5, BAND_DATA6, BAND_DATA7, BAND_DATA8, ALL = 0xff }; +enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, AGC_LVL, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TOT, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, BAND_DATA0, BAND_DATA1, BAND_DATA2, BAND_DATA3, BAND_DATA4, BAND_DATA5, BAND_DATA6, BAND_DATA7, BAND_DATA8, ALL = 0xff }; #else #define I_PARAMS 5 -enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, AGC_LVL, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, ALL = 0xff }; +enum params_t { _NULL, VOLUME, MODE, FILTER, BAND, STEP, VFOSEL, RIT, AGC, AGC_LVL, NR, ATT, ATT2, SMETER, CWDEC, CWTONE, CWOFF, KEY_WPM, KEY_MODE, KEY_PIN, TONE_VOL, VOX, VOXGAIN, DRIVE, TOT, TXDELAY, MOX, CWINTERVAL, CWMSG1, CWMSG2, CWMSG3, CWMSG4, CWMSG5, CWMSG6, PWM_MIN, PWM_MAX, SIFXTAL, IQ_ADJ, CAT_ACTIVE, CAT_BAUD, CALIB, SR, CPULOAD, PARAM_A, PARAM_B, PARAM_C, BACKL, FREQA, FREQB, MODEA, MODEB, VERS, ALL = 0xff }; #endif #define N_ALL_PARAMS (N_PARAMS+I_PARAMS) // number of parameters @@ -4937,6 +4959,7 @@ int8_t paramAction(uint8_t action, uint8_t id = ALL) // list of parameters case VOXGAIN: paramAction(action, vox_thresh, 0x32, F("Noise Gate"), NULL, 0, 255, false); break; // GW8RDI mod - "Noise Gate" save bytes #endif case DRIVE: paramAction(action, drive, 0x33, F("TX Drive"), NULL, 0, 8, false); break; + case TOT: paramAction(action, tot, 0x34, F("TX Timeout"), NULL, 0, 255, false); break; // TX time-out timer, seconds; 0 = disabled — force-unkeys the PA if PTT sticks #ifdef TX_DELAY case TXDELAY: paramAction(action, txdelay, 0x34, F("TX Delay"), NULL, 0, 255, false); break; #endif @@ -5106,6 +5129,8 @@ void analyseCATcmd() // Supported Kenwood TS-480 protocol CAT commands else if (CMD('A','G','0') && CATcmd[4]==';') Command_AG0_SET(); else if (CMD('A','L',';')) Command_AL_GET(); else if (CMD2('A','L') && CATcmd[2]!=';') Command_AL_SET(); + else if (CMD('T','T',';')) Command_TT_GET(); + else if (CMD2('T','T') && CATcmd[2]!=';') Command_TT_SET(); else if (CMD('F','W',';')) Command_FW_GET(); else if (CMD2('F','W') && CATcmd[3]==';') Command_FW_SET(); else if (CMD('X','T','1')) Command_XT1(); @@ -5474,6 +5499,22 @@ void Command_AL_SET() Command_AL_GET(); } +// TT; → get TX time-out timer (seconds, 0=disabled), TTn; → set (0..255). +// Guardrail against a stuck PTT overheating the PA. Note: changing it does not +// re-arm an already-running TX — the new limit applies from the next keying. +void Command_TT_GET() +{ + char buf[7]; + sprintf(buf, "TT%u;", (uint8_t)tot); + Serial.print(buf); +} +void Command_TT_SET() +{ + uint16_t v = (uint16_t)atoi(CATcmd + 2); + if (v <= 255) { tot = (uint8_t)v; paramAction(SAVE, TOT); } + Command_TT_GET(); +} + // FW; → get filter index (0..7), FWn; → set filter index void Command_FW_GET() { @@ -5960,6 +6001,10 @@ static int32_t _step = 0; void loop() { + if (tx && tx_timeout_at && (millis() > tx_timeout_at)) { // TX time-out guard (CAT-keyed TX; the physical-PTT hold loop has its own copy) + switch_rxtx(0); + lcd.setCursor(0, 1); lcd.print(F("TX TIMEOUT!")); lcd_blanks(); + } #ifdef VOX_ENABLE if ((vox) && ((mode == LSB) || (mode == USB))) { // If VOX enabled (and in LSB/USB mode), then take mic samples and feed ssb processing function, to derive amplitude, and potentially detect cross vox_threshold to detect a TX or RX event: this is expressed in tx variable if (!vox_tx) { // VOX not active @@ -6107,6 +6152,12 @@ void loop() wdt_reset(); delay((mode == CW) ? 10 : 100); // keep the tx keyed for a while before sensing (helps against RFI issues on DAH/DAH line) if (inv ^ _digitalRead(BUTTONS)) break; // break if button is pressed (to prevent potential lock-up) + if (tx_timeout_at && (millis() > tx_timeout_at)) { // TX time-out guard: loop() is blocked while the key is held, so check here too + switch_rxtx(0); + lcd.setCursor(0, 1); lcd.print(F("TX TIMEOUT!")); lcd_blanks(); + for (; !_digitalRead(pin);) wdt_reset(); // require key release before TX can be keyed again + break; + } } while (!_digitalRead(pin)); // until released switch_rxtx(0); } diff --git a/scripts/cat-hardware-test.ts b/scripts/cat-hardware-test.ts index e96c0ec..30be08a 100644 --- a/scripts/cat-hardware-test.ts +++ b/scripts/cat-hardware-test.ts @@ -98,7 +98,7 @@ function framesByPrefix(frames: string[]): Map { // BL (backlight) is polled again since the 2026-07-04 firmware fix (BACKLIGHT_PIN // moved to the correct pin, PD3). PM/PX (PA bias) are deliberately NOT polled — // the app fetches them on demand when its PA settings panel opens. -const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;']; +const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;', 'TT;']; // ── Test bed ────────────────────────────────────────────────────────────────── @@ -293,6 +293,23 @@ function main(): void { record('AL SET of 0 is rejected (echo returns old value)', parseIntField(alRejectResp, 'AL') === alBefore, JSON.stringify(alRejectResp)); } + // ── SET/GET round-trip on the TX time-out timer (TT, 0..255 s), restoring + // original. The guard itself (force-unkey) is NOT exercised — that would + // require keying TX, which automated tests never do. ── + const ttBeforeResp = sendGet(fd, 'TT;', 'TT'); + const ttBefore = parseIntField(ttBeforeResp, 'TT'); + record('TT; GET returns a value in 0..255', ttBefore !== null && ttBefore >= 0 && ttBefore <= 255, JSON.stringify(ttBeforeResp)); + + if (ttBefore !== null) { + const ttProbe = ttBefore === 60 ? 120 : 60; + const ttSetResp = send(fd, `TT${ttProbe};`); + record('TT SET echoes the new value', parseIntField(ttSetResp, 'TT') === ttProbe, JSON.stringify(ttSetResp)); + const ttRestoreResp = send(fd, `TT${ttBefore};`); + record('TT restored to its original value', parseIntField(ttRestoreResp, 'TT') === ttBefore, JSON.stringify(ttRestoreResp)); + const ttRejectResp = send(fd, 'TT999;'); + record('TT SET beyond 255 is rejected (echo returns old value)', parseIntField(ttRejectResp, 'TT') === ttBefore, JSON.stringify(ttRejectResp)); + } + // ── AG0 SET beyond 1 must be rejected since the single-algorithm AGC change ── const agBeforeResp = sendGet(fd, 'AG0;', 'AG0'); const agBefore = parseIntField(agBeforeResp, 'AG0'); diff --git a/src/components/RadioCATPanel.tsx b/src/components/RadioCATPanel.tsx index 65c903e..168959c 100644 --- a/src/components/RadioCATPanel.tsx +++ b/src/components/RadioCATPanel.tsx @@ -501,7 +501,7 @@ function FactoryResetButton({ onConfirm }: { onConfirm: () => void }) { // read-only reading, not a control. PA bias lives in its own on-demand panel // (PABiasPanel) behind the wrench button — not polled, queried when opened. -function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, drive, backlight, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onAgcLevel, onFilter, onDrive, onBacklight, onTogglePA, onReset }: { +function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, drive, backlight, txTimeout, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onAgcLevel, onFilter, onDrive, onBacklight, onTxTimeout, onTogglePA, onReset }: { volume: number | null; att1: number | null; att2: number | null; @@ -511,6 +511,7 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri filter: number | null; drive: number | null; backlight: number | null; + txTimeout: number | null; paOpen: boolean; onVolume: (n: number) => void; onAtt1: (n: number) => void; @@ -521,6 +522,7 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri onFilter: (n: number) => void; onDrive: (n: number) => void; onBacklight: (n: number) => void; + onTxTimeout: (n: number) => void; onTogglePA: () => void; onReset: () => void; }) { @@ -533,6 +535,8 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri + {/* TX time-out guard (TT, seconds; 0 = disabled) — firmware force-unkeys a stuck TX */} +
Auto Gain Control @@ -749,8 +753,8 @@ const DEFAULT_CONFIG: CATConnectionConfig & { presetIdx: number } = { }; export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnType; collapsed?: boolean }) { - const { state, connect, disconnect, setFrequency, setMode, setPTT, setVolume, setAtt1, setAtt2, setNR, setAGC, setAgcLevel, setFilter, setDrive, setBacklight, getPABias, setPABias, resetRadio, getFactoryDefaults, factoryResetRadio } = cat; - const { connected, frequency, mode, ptt, error, isSupported, volume, att1, att2, nr, agc, agcLevel, filter, sMeter, drive, backlight } = state; + const { state, connect, disconnect, setFrequency, setMode, setPTT, setVolume, setAtt1, setAtt2, setNR, setAGC, setAgcLevel, setTxTimeout, setFilter, setDrive, setBacklight, getPABias, setPABias, resetRadio, getFactoryDefaults, factoryResetRadio } = cat; + const { connected, frequency, mode, ptt, error, isSupported, volume, att1, att2, nr, agc, agcLevel, filter, sMeter, drive, backlight, txTimeout } = state; const [showSettings, setShowSettings] = useState(false); const [showPABias, setShowPABias] = useState(false); @@ -767,6 +771,7 @@ export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnT const handleNR = useCallback((n: number) => { setNR(n).catch(() => {}); }, [setNR]); const handleAGC = useCallback((n: number) => { setAGC(n).catch(() => {}); }, [setAGC]); const handleAgcLevel = useCallback((n: number) => { setAgcLevel(n).catch(() => {}); }, [setAgcLevel]); + const handleTxTimeout = useCallback((n: number) => { setTxTimeout(n).catch(() => {}); }, [setTxTimeout]); const handleFilter = useCallback((n: number) => { setFilter(n).catch(() => {}); }, [setFilter]); const handleDrive = useCallback((n: number) => { setDrive(n).catch(() => {}); }, [setDrive]); const handleBacklight = useCallback((n: number) => { setBacklight(n).catch(() => {}); }, [setBacklight]); @@ -850,12 +855,12 @@ export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnT {config.rigProfile === 'usdx-blackbrick' && !collapsed && ( )} diff --git a/src/hooks/useRadioCAT.ts b/src/hooks/useRadioCAT.ts index 9324005..d8c5372 100644 --- a/src/hooks/useRadioCAT.ts +++ b/src/hooks/useRadioCAT.ts @@ -71,6 +71,10 @@ export interface RadioState { drive: number | null; /** LCD backlight: 0=off, 1=on. */ backlight: number | null; + /** TX time-out timer in seconds (TT command), 0 = disabled. Firmware + * force-unkeys the PA when a TX exceeds this — guardrail against a stuck + * PTT overheating the finals. Default 180. */ + txTimeout: number | null; } /** PA bias PWM endpoints (see PM/PX commands) — not polled; fetched on demand. */ @@ -110,6 +114,7 @@ export interface RadioCATControls { setNR: (n: number) => Promise; setAGC: (n: number) => Promise; setAgcLevel: (n: number) => Promise; + setTxTimeout: (n: number) => Promise; setFilter: (n: number) => Promise; setDrive: (n: number) => Promise; setBacklight: (n: number) => Promise; @@ -201,10 +206,13 @@ export interface RadioCATControls { // value. NOT polled — changing them rebuilds the // PA PWM LUT, so they're fetched/set on demand // from the PA settings panel only.) +// Query TX timeout: TT; → TTn; (0..255 s, 0 = disabled — TOT guardrail that +// Set TX timeout: TTn; → TTn; force-unkeys a stuck TX; out-of-range SETs +// are ignored, echo returns the old value) // The firmware supports serialized/batched queries in one write, e.g. -// "FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;" — replies come back concatenated in the same order. +// "FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;TT;" — replies come back concatenated in the same order. -const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;']; +const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;', 'TT;']; const KENWOOD_MODE_MAP: Record = { '1': 'LSB', '2': 'USB', '3': 'CW', '4': 'FM', '5': 'AM', '6': 'RTTY', @@ -242,7 +250,7 @@ export function useRadioCAT(): RadioCATControls { ptt: false, error: null, isSupported: false, volume: null, att1: null, att2: null, nr: null, agc: null, agcLevel: null, filter: null, sMeter: null, drive: null, - backlight: null, + backlight: null, txTimeout: null, }); useEffect(() => { @@ -273,8 +281,8 @@ export function useRadioCAT(): RadioCATControls { // a clean teardown when the port disappears out from under us. const disconnectRef = useRef<() => void>(() => {}); - const lastSetRef = useRef<{ frequency: number; mode: number; volume: number; att1: number; att2: number; nr: number; agc: number; agcLevel: number; filter: number; drive: number; backlight: number }>({ - frequency: 0, mode: 0, volume: 0, att1: 0, att2: 0, nr: 0, agc: 0, agcLevel: 0, filter: 0, drive: 0, backlight: 0, + const lastSetRef = useRef<{ frequency: number; mode: number; volume: number; att1: number; att2: number; nr: number; agc: number; agcLevel: number; filter: number; drive: number; backlight: number; txTimeout: number }>({ + frequency: 0, mode: 0, volume: 0, att1: 0, att2: 0, nr: 0, agc: 0, agcLevel: 0, filter: 0, drive: 0, backlight: 0, txTimeout: 0, }); const log = useCallback((level: 'debug' | 'info' | 'warn' | 'error', ...args: unknown[]) => { @@ -526,6 +534,7 @@ export function useRadioCAT(): RadioCATControls { const drive = byPrefix.has('DR') ? parseIntField(byPrefix.get('DR')!, 'DR') : null; const backlight = byPrefix.has('BL') ? parseIntField(byPrefix.get('BL')!, 'BL') : null; const agcLevel = byPrefix.has('AL') ? parseIntField(byPrefix.get('AL')!, 'AL') : null; + const txTimeout = byPrefix.has('TT') ? parseIntField(byPrefix.get('TT')!, 'TT') : null; log('debug', 'poll(batch) — freq:', freq, 'mode:', mode, 'agc:', agc, 'agcLvl:', agcLevel, 'filt:', filter, 'vol:', volume, 'att1:', att1, 'att2:', att2, 'nr:', nr, 'sm:', sMeter, 'drive:', drive, 'bl:', backlight, `[q:${queueRef.current.length}]`); @@ -536,6 +545,7 @@ export function useRadioCAT(): RadioCATControls { mode: mode !== null && (now - ls.mode > SET_GRACE_MS) ? mode : prev.mode, agc: agc !== null && (now - ls.agc > SET_GRACE_MS) ? agc : prev.agc, agcLevel: agcLevel !== null && (now - ls.agcLevel > SET_GRACE_MS) ? agcLevel : prev.agcLevel, + txTimeout: txTimeout !== null && (now - ls.txTimeout > SET_GRACE_MS) ? txTimeout : prev.txTimeout, filter: filter !== null && (now - ls.filter > SET_GRACE_MS) ? filter : prev.filter, volume: volume !== null && (now - ls.volume > SET_GRACE_MS) ? volume : prev.volume, att1: att1 !== null && (now - ls.att1 > SET_GRACE_MS) ? att1 : prev.att1, @@ -599,7 +609,7 @@ export function useRadioCAT(): RadioCATControls { setState(prev => ({ ...prev, connected: false, frequency: null, mode: null, ptt: false, error: null, volume: null, att1: null, att2: null, nr: null, agc: null, agcLevel: null, filter: null, sMeter: null, drive: null, - backlight: null, + backlight: null, txTimeout: null, })); }, [log]); disconnectRef.current = disconnect; // keep the read loop's teardown handle current @@ -715,6 +725,13 @@ export function useRadioCAT(): RadioCATControls { await write(`AL${n};`); }, [log, write]); + const setTxTimeout = useCallback(async (n: number) => { + lastSetRef.current.txTimeout = Date.now(); + log('info', 'setTxTimeout →', n); + setState(prev => ({ ...prev, txTimeout: n })); + await write(`TT${n};`); + }, [log, write]); + const setFilter = useCallback(async (n: number) => { lastSetRef.current.filter = Date.now(); log('info', 'setFilter →', n); @@ -806,7 +823,7 @@ export function useRadioCAT(): RadioCATControls { return { state, connect, disconnect, setFrequency, setMode, setPTT, - setVolume, setAtt1, setAtt2, setNR, setAGC, setAgcLevel, setFilter, setDrive, + setVolume, setAtt1, setAtt2, setNR, setAGC, setAgcLevel, setTxTimeout, setFilter, setDrive, setBacklight, getPABias, setPABias, resetRadio, getFactoryDefaults, factoryResetRadio, getRefFreq, setRefFreq, }; diff --git a/src/lib/cat/__tests__/protocol.test.ts b/src/lib/cat/__tests__/protocol.test.ts index 78d4d65..13f92fc 100644 --- a/src/lib/cat/__tests__/protocol.test.ts +++ b/src/lib/cat/__tests__/protocol.test.ts @@ -3,7 +3,7 @@ * * These tests are pure JS: no hardware, no serial port, no browser APIs. * They validate command string construction, response parsing, multi-command - * batching, and all custom PU7FTW extension commands (BL/VO/TQ/AT/A2/NR/AG0/AL/FW/SM/DR/PM/PX). + * batching, and all custom PU7FTW extension commands (BL/VO/TQ/AT/A2/NR/AG0/AL/TT/FW/SM/DR/PM/PX). * Note: BL (backlight) is polled and surfaced in the UI again since the * 2026-07-04 firmware fix (BACKLIGHT_PIN moved to the correct pin, PD3). * PM/PX (PA bias endpoints) are deliberately NOT polled — they're fetched @@ -119,6 +119,11 @@ describe('command construction', () => { expect('AL14;').toMatch(/^AL\d{1,2};$/); }); + test('TT set — TX time-out seconds, 0..255 (0 = disabled)', () => { + expect('TT0;').toMatch(/^TT\d{1,3};$/); + expect('TT180;').toMatch(/^TT\d{1,3};$/); + }); + test('FW set — 0..7', () => { for (let i = 0; i <= 7; i++) { expect(`FW${i};`).toMatch(/^FW\d;$/); @@ -213,6 +218,12 @@ describe('response parsing — custom BLACK_BRICK commands', () => { expect(parseIntField('AL;', 'AL')).toBeNull(); }); + test('TT — TX time-out timer 0..255 s (default 180; firmware force-unkeys TX past the limit)', () => { + expect(parseIntField('TT180;', 'TT')).toBe(180); // firmware default + expect(parseIntField('TT0;', 'TT')).toBe(0); // disabled + expect(parseIntField('TT;', 'TT')).toBeNull(); + }); + test('FW — filter index 0..7', () => { expect(parseIntField('FW0;', 'FW')).toBe(0); // Full expect(parseIntField('FW4;', 'FW')).toBe(4); // 500 Hz @@ -281,11 +292,11 @@ describe('response parsing — custom BLACK_BRICK commands', () => { }); describe('multi-command / batched poll parsing', () => { - const BATCH_RESPONSE = 'FA00014225000;MD2;AG01;FW3;VO8;AT2;A216;NR4;SM-68;DR5;BL1;AL4;'; + const BATCH_RESPONSE = 'FA00014225000;MD2;AG01;FW3;VO8;AT2;A216;NR4;SM-68;DR5;BL1;AL4;TT180;'; - test('splitFrames — all 12 frames', () => { + test('splitFrames — all 13 frames', () => { const frames = splitFrames(BATCH_RESPONSE); - expect(frames).toHaveLength(12); + expect(frames).toHaveLength(13); expect(frames[0]).toBe('FA00014225000;'); expect(frames[1]).toBe('MD2;'); expect(frames[2]).toBe('AG01;'); @@ -377,10 +388,10 @@ describe('IF frame parsing', () => { }); describe('BLACKBRICK_POLL_CMDS array', () => { - const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;']; + const BLACKBRICK_POLL_CMDS = ['FA;', 'MD;', 'AG0;', 'FW;', 'VO;', 'AT;', 'A2;', 'NR;', 'SM;', 'DR;', 'BL;', 'AL;', 'TT;']; - test('12 commands in poll array', () => { - expect(BLACKBRICK_POLL_CMDS).toHaveLength(12); + test('13 commands in poll array', () => { + expect(BLACKBRICK_POLL_CMDS).toHaveLength(13); }); test('AG0; is included', () => { @@ -410,7 +421,7 @@ describe('BLACKBRICK_POLL_CMDS array', () => { test('prefixes derived from commands', () => { const prefixes = BLACKBRICK_POLL_CMDS.map(c => c.substring(0, 2)); - expect(prefixes).toEqual(['FA', 'MD', 'AG', 'FW', 'VO', 'AT', 'A2', 'NR', 'SM', 'DR', 'BL', 'AL']); + expect(prefixes).toEqual(['FA', 'MD', 'AG', 'FW', 'VO', 'AT', 'A2', 'NR', 'SM', 'DR', 'BL', 'AL', 'TT']); }); }); diff --git a/src/lib/cat/mockSerial.ts b/src/lib/cat/mockSerial.ts index f6b09d1..28e6c9f 100644 --- a/src/lib/cat/mockSerial.ts +++ b/src/lib/cat/mockSerial.ts @@ -46,7 +46,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { const rig = { frequency: opts.initialFrequency ?? 7074000, modeDigit: opts.modeDigit ?? 2, // USB - agc: 1, agcLevel: 4, filter: 1, volume: 10, att1: 0, att2: 0, nr: 0, drive: 4, + agc: 1, agcLevel: 4, txTimeout: 180, filter: 1, volume: 10, att1: 0, att2: 0, nr: 0, drive: 4, sMeter: 7, backlight: 1, pwmMin: 10, pwmMax: 130, // PA bias endpoints (firmware defaults) @@ -78,6 +78,12 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { if (v >= 1 && v <= 14) rig.agcLevel = v; return `AL${rig.agcLevel};`; } + const ttSet = cmd.match(/^TT(\d+)$/); + if (ttSet) { + const v = parseInt(ttSet[1], 10); + if (v <= 255) rig.txTimeout = v; + return `TT${rig.txTimeout};`; + } // set-commands carry a payload and (like the real firmware) get no reply const set = cmd.match(/^([A-Z]{2})([0-9-]+.*)$/); if (set && cmd.length > 3) { @@ -132,6 +138,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { } case 'DR': return `DR${rig.drive};`; case 'AL': return `AL${rig.agcLevel};`; + case 'TT': return `TT${rig.txTimeout};`; case 'BL': return `BL${rig.backlight};`; case 'PM': return `PM${rig.pwmMin};`; case 'PX': return `PX${rig.pwmMax};`; @@ -142,7 +149,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { // Factory reset: acks SR2; and restores the firmware defaults (the real // radio also reboots — same "no simulated outage" caveat as SR). case 'SR2': - rig.modeDigit = 2; rig.agc = 1; rig.agcLevel = 4; rig.filter = 0; rig.volume = 11; + rig.modeDigit = 2; rig.agc = 1; rig.agcLevel = 4; rig.txTimeout = 180; rig.filter = 0; rig.volume = 11; rig.att1 = 0; rig.att2 = 0; rig.nr = 0; rig.drive = 4; rig.backlight = 1; rig.pwmMin = 10; rig.pwmMax = 130; return 'SR2;'; From 0b20e2bec8a03a0ff02d4eaaa804837279fea007 Mon Sep 17 00:00:00 2001 From: Igor Nogueira de Oliveira Date: Mon, 6 Jul 2026 22:56:48 -0300 Subject: [PATCH 3/6] Firmware: FV version command, TT default 30s, no S-meter reading during TX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FV; CAT command returning the firmware version; web app queries it on connect and gates the PU7FTW extension controls on the answer instead of hardcoding versions (preset label is now version-less). - TX time-out default lowered to 30s. - SM; replies an empty frame during TX (nothing to measure while the ADC samples the mic); app shows no reading instead of a stale value. - Revert version string to 4.01a — versions only bump at release. - Hardware bed: verify-and-retry all restore SETs (a noise-eaten AT restore had left the attenuator stuck at -73dB), retry the SM-after-attenuation read that races the throttled LCD redraw, FV check (51 checks). --- README.md | 6 ++- firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino | 16 +++++++- scripts/cat-hardware-test.ts | 42 +++++++++++++++------ src/components/RadioCATPanel.tsx | 19 +++++++--- src/hooks/useRadioCAT.ts | 44 ++++++++++++++++++---- src/lib/cat/__tests__/protocol.test.ts | 14 ++++++- src/lib/cat/mockSerial.ts | 6 ++- 7 files changed, 114 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0c288d9..8309173 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ If the port is busy or silent, make sure no other CAT application (hamlib, rigct ### Custom CAT commands (PU7FTW extensions) -All commands terminate with `;`. All SET commands echo the effective value as a GET reply — if a SET is rejected (out of range), the echo returns the **old** value, which the web app relies on. Commands are safe to batch in a single write — the firmware processes them in order and concatenates replies, e.g. `FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;` (the app's poll batch) returns all 12 replies in one read window. +All commands terminate with `;`. All SET commands echo the effective value as a GET reply — if a SET is rejected (out of range), the echo returns the **old** value, which the web app relies on. Commands are safe to batch in a single write — the firmware processes them in order and concatenates replies, e.g. `FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;TT;` (the app's poll batch) returns all 13 replies in one read window. | Command | Query | Set | Range | Notes | |---------|-------|-----|-------|-------| @@ -483,8 +483,10 @@ All commands terminate with `;`. All SET commands echo the effective value as a | **AG0** — AGC on/off | `AG0;` → `AG0n;` | `AG0n;` | 0…1 | single algorithm (M0PUB fast-attack/slow-decay); values >1 rejected | | **AL** — AGC level | `AL;` → `ALn;` | `ALn;` | 1…14 | target window: output peaks held in [n·256 … n·384]; default 4 | | **FW** — filter BW | `FW;` → `FWn;` | `FWn;` | 0…7 | 0=Full 1=3k 2=2.4k 3=1.8k 4=500 5=200 6=100 7=50 Hz | -| **SM** — S-meter | `SM;` → `SMn;` | — | dBm (signed) | read-only; integer-math dBm, ±1 dB vs the old float version | +| **SM** — S-meter | `SM;` → `SMn;` | — | dBm (signed) | read-only; integer-math dBm, ±1 dB vs the old float version. During TX replies an empty `SM;` (no RX signal to measure) | | **DR** — TX drive | `DR;` → `DRn;` | `DRn;` | 0…8 | pre-clipping input gain (each step doubles the envelope); 8 = always max | +| **TT** — TX time-out | `TT;` → `TTn;` | `TTn;` | 0…255 s | TOT guardrail: force-unkeys the PA past the limit; 0 = disabled, default 30 | +| **FV** — firmware version | `FV;` → `FV4.01a;` | — | | read-only; the web app gates its extension controls on this instead of hardcoding the version | | **PM** — PA bias min | `PM;` → `PMn;` | `PMn;` | 0…max−1 | PWM LUT idle endpoint; SET rebuilds the LUT live | | **PX** — PA bias max | `PX;` → `PXn;` | `PXn;` | min+1…255 | PWM LUT full-drive endpoint — the real output-power control for digital modes | | **XF** — ref frequency | `XF;` → `XFnnnnnnnn;` | `XFnnnnnnnn;` | 14–28 MHz | si5351 fxtal calibration; used by the calibration wizard | diff --git a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino index d31c20a..ac6a6c7 100644 --- a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino +++ b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino @@ -4356,7 +4356,7 @@ uint32_t semi_qsk_timeout = 0; // TX time-out timer (TOT): hard guardrail against a stuck PTT overheating the PA. // tot = limit in seconds (0 = disabled); armed by switch_rxtx(1), checked from // loop() (CAT TX) and inside the physical-PTT hold loop (which blocks loop()). -volatile uint8_t tot = 180; +volatile uint8_t tot = 30; volatile uint32_t tx_timeout_at = 0; // Set RX or TX mode, with RIT support and CW offset @@ -5131,6 +5131,7 @@ void analyseCATcmd() // Supported Kenwood TS-480 protocol CAT commands else if (CMD2('A','L') && CATcmd[2]!=';') Command_AL_SET(); else if (CMD('T','T',';')) Command_TT_GET(); else if (CMD2('T','T') && CATcmd[2]!=';') Command_TT_SET(); + else if (CMD('F','V',';')) Command_FV(); else if (CMD('F','W',';')) Command_FW_GET(); else if (CMD2('F','W') && CATcmd[3]==';') Command_FW_SET(); else if (CMD('X','T','1')) Command_XT1(); @@ -5499,6 +5500,14 @@ void Command_AL_SET() Command_AL_GET(); } +// FV; → get firmware version (read-only), e.g. "FV4.01a;" — mirrors Kenwood's +// FV command. Lets CAT clients (the web app) discover the running firmware and +// enable only the features it supports, instead of hardcoding the version. +void Command_FV() +{ + Serial.print(F("FV" VERSION ";")); +} + // TT; → get TX time-out timer (seconds, 0=disabled), TTn; → set (0..255). // Guardrail against a stuck PTT overheating the PA. Note: changing it does not // re-arm an already-running TX — the new limit applies from the next keying. @@ -5576,6 +5585,11 @@ void Command_NR_SET() // display mode (smode) — see the smeter() comment for why that matters. void Command_SM_GET() { + // During TX there is no RX signal to measure (the single ADC samples the mic, + // the QSD is muted) — `dbm` would just be the stale last-RX value. Reply an + // empty "SM;" frame instead: batched polls stay aligned (prefix present) and + // clients parse it as "no reading" rather than a misleading number. + if (tx) { Serial.print("SM;"); return; } char buf[8]; sprintf(buf, "SM%d;", (int)dbm); Serial.print(buf); diff --git a/scripts/cat-hardware-test.ts b/scripts/cat-hardware-test.ts index 30be08a..b1dacfe 100644 --- a/scripts/cat-hardware-test.ts +++ b/scripts/cat-hardware-test.ts @@ -69,6 +69,19 @@ function sendGet(fd: number, cmd: string, prefix: string, waitMs = 400): string return send(fd, cmd, waitMs); } +// For RESTORE SETs only: absolute-value SETs (ATn;, DRn;, ...) are idempotent, +// so re-sending after a noise-eaten echo is safe — and a restore MUST be +// confirmed, or the radio is left drifted (an unconfirmed AT7 restore once left +// the attenuator stuck at max, -73dB, making the radio seem dead/frozen). +function sendSetVerified(fd: number, cmd: string, prefix: string, expected: number): string { + let resp = ''; + for (let i = 0; i < 3; i++) { + resp = send(fd, cmd); + if (parseIntField(resp, prefix) === expected) return resp; + } + return resp; +} + // ── Same parse helpers as useRadioCAT.ts / protocol.test.ts ────────────────── // This board's LCD shares its data pins with the UART (see usdxBLACKBRICK.ino @@ -184,7 +197,7 @@ function main(): void { const confirmed = parseIntField(confirmResp, 'AT'); record('AT GET reflects the newly set value', confirmed === probe, JSON.stringify(confirmResp)); - const restoreResp = send(fd, `AT${before};`); + const restoreResp = sendSetVerified(fd, `AT${before};`, 'AT', before); const restored = parseIntField(restoreResp, 'AT'); record('AT restored to its original value', restored === before, JSON.stringify(restoreResp)); } @@ -199,15 +212,15 @@ function main(): void { record('SM; GET returns a reading before attenuator change', smBefore !== null, JSON.stringify(smBeforeResp)); if (smBefore !== null && before !== null) { - send(fd, 'AT7;'); // max analog attenuation (-73dB per att_label[]) - const smAfterResp = send(fd, 'SM;', 600); + sendSetVerified(fd, 'AT7;', 'AT', 7); // max analog attenuation (-73dB per att_label[]) + const smAfterResp = sendGet(fd, 'SM;', 'SM-', 600); // retry: the throttled LCD redraw fires exactly when dBm changes and can eat this reply const smAfter = parseIntField(smAfterResp, 'SM'); record( 'SM; reading changes after a real hardware attenuation change (not frozen)', smAfter !== null && smAfter !== smBefore, `before=${smBefore} after=${smAfter}`, ); - const smRestoreResp = send(fd, `AT${before};`); + const smRestoreResp = sendSetVerified(fd, `AT${before};`, 'AT', before); record('AT restored after S-meter liveness check', parseIntField(smRestoreResp, 'AT') === before, JSON.stringify(smRestoreResp)); } @@ -226,7 +239,7 @@ function main(): void { const confirmed = parseIntField(confirmResp, 'DR'); record('DR GET reflects the newly set value', confirmed === probe, JSON.stringify(confirmResp)); - const restoreResp = send(fd, `DR${drBefore};`); + const restoreResp = sendSetVerified(fd, `DR${drBefore};`, 'DR', drBefore); const restored = parseIntField(restoreResp, 'DR'); record('DR restored to its original value', restored === drBefore, JSON.stringify(restoreResp)); } @@ -242,7 +255,7 @@ function main(): void { const blProbe = blBefore === 1 ? 0 : 1; const blSetResp = send(fd, `BL${blProbe};`); record('BL SET echoes the new value', parseIntField(blSetResp, 'BL') === blProbe, JSON.stringify(blSetResp)); - const blRestoreResp = send(fd, `BL${blBefore};`); + const blRestoreResp = sendSetVerified(fd, `BL${blBefore};`, 'BL', blBefore); record('BL restored to its original value', parseIntField(blRestoreResp, 'BL') === blBefore, JSON.stringify(blRestoreResp)); } @@ -273,7 +286,8 @@ function main(): void { send(fd, `MD${mdBefore};`); sleepMs(150); - const mdRestored = sendGet(fd, 'MD;', 'MD'); + let mdRestored = sendGet(fd, 'MD;', 'MD'); + if (parseIntField(mdRestored, 'MD') !== mdBefore) { send(fd, `MD${mdBefore};`); sleepMs(150); mdRestored = sendGet(fd, 'MD;', 'MD'); } record('MD restored to its original value', parseIntField(mdRestored, 'MD') === mdBefore, JSON.stringify(mdRestored)); } @@ -287,7 +301,7 @@ function main(): void { const alProbe = alBefore === 4 ? 6 : 4; const alSetResp = send(fd, `AL${alProbe};`); record('AL SET echoes the new value', parseIntField(alSetResp, 'AL') === alProbe, JSON.stringify(alSetResp)); - const alRestoreResp = send(fd, `AL${alBefore};`); + const alRestoreResp = sendSetVerified(fd, `AL${alBefore};`, 'AL', alBefore); record('AL restored to its original value', parseIntField(alRestoreResp, 'AL') === alBefore, JSON.stringify(alRestoreResp)); const alRejectResp = send(fd, 'AL0;'); record('AL SET of 0 is rejected (echo returns old value)', parseIntField(alRejectResp, 'AL') === alBefore, JSON.stringify(alRejectResp)); @@ -304,7 +318,7 @@ function main(): void { const ttProbe = ttBefore === 60 ? 120 : 60; const ttSetResp = send(fd, `TT${ttProbe};`); record('TT SET echoes the new value', parseIntField(ttSetResp, 'TT') === ttProbe, JSON.stringify(ttSetResp)); - const ttRestoreResp = send(fd, `TT${ttBefore};`); + const ttRestoreResp = sendSetVerified(fd, `TT${ttBefore};`, 'TT', ttBefore); record('TT restored to its original value', parseIntField(ttRestoreResp, 'TT') === ttBefore, JSON.stringify(ttRestoreResp)); const ttRejectResp = send(fd, 'TT999;'); record('TT SET beyond 255 is rejected (echo returns old value)', parseIntField(ttRejectResp, 'TT') === ttBefore, JSON.stringify(ttRejectResp)); @@ -333,13 +347,13 @@ function main(): void { const pmProbe = pmBefore + 1 < pxBefore ? pmBefore + 1 : pmBefore - 1; // stay in [0, max-1] const pmSetResp = send(fd, `PM${pmProbe};`); record('PM SET echoes the new value', parseIntField(pmSetResp, 'PM') === pmProbe, JSON.stringify(pmSetResp)); - const pmRestoreResp = send(fd, `PM${pmBefore};`); + const pmRestoreResp = sendSetVerified(fd, `PM${pmBefore};`, 'PM', pmBefore); record('PM restored to its original value', parseIntField(pmRestoreResp, 'PM') === pmBefore, JSON.stringify(pmRestoreResp)); const pxProbe = pxBefore > pmBefore + 1 ? pxBefore - 1 : pxBefore + 1; // stay in [min+1, 255] const pxSetResp = send(fd, `PX${pxProbe};`); record('PX SET echoes the new value', parseIntField(pxSetResp, 'PX') === pxProbe, JSON.stringify(pxSetResp)); - const pxRestoreResp = send(fd, `PX${pxBefore};`); + const pxRestoreResp = sendSetVerified(fd, `PX${pxBefore};`, 'PX', pxBefore); record('PX restored to its original value', parseIntField(pxRestoreResp, 'PX') === pxBefore, JSON.stringify(pxRestoreResp)); // Out-of-range SET must be rejected: the echo returns the unchanged value. @@ -360,13 +374,17 @@ function main(): void { const xfProbe = xfBefore + 5; const xfSetResp = send(fd, `XF${xfProbe};`); record('XF SET echoes the new value', parseIntField(xfSetResp, 'XF') === xfProbe, JSON.stringify(xfSetResp)); - const xfRestoreResp = send(fd, `XF${xfBefore};`); + const xfRestoreResp = sendSetVerified(fd, `XF${xfBefore};`, 'XF', xfBefore); record('XF restored to its original value', parseIntField(xfRestoreResp, 'XF') === xfBefore, JSON.stringify(xfRestoreResp)); // Out-of-range must be rejected (echo returns unchanged value) const xfRejectResp = send(fd, 'XF999;'); record('XF SET below 14 MHz is rejected (echo returns old value)', parseIntField(xfRejectResp, 'XF') === xfBefore, JSON.stringify(xfRejectResp)); } + // ── FV; firmware version (read-only) ── + const fvResp = sendGet(fd, 'FV;', 'FV'); + record('FV; returns a firmware version string', /FV\d\.\d\d[a-z]?;/.test(fvResp), JSON.stringify(fvResp)); + // ── FD; factory-defaults frame — one 11-value CSV frame. NOTE: SR2; // (factory reset) is deliberately NOT exercised here — it wipes band // memories and calibration on every run. Test it manually from the UI. ── diff --git a/src/components/RadioCATPanel.tsx b/src/components/RadioCATPanel.tsx index 168959c..314a456 100644 --- a/src/components/RadioCATPanel.tsx +++ b/src/components/RadioCATPanel.tsx @@ -22,7 +22,7 @@ interface RadioPreset { } const RADIO_PRESETS: RadioPreset[] = [ - { label: 'uSDX BLACK_BRICK 4.01a', baudRate: 38400, dataBits: 8, stopBits: 1, parity: 'none', notes: 'PU7FTW custom firmware — adds volume, attenuator, noise reduction, AGC, filter, TX drive, backlight, PA bias and S-meter controls, batched CAT polling', rigProfile: 'usdx-blackbrick' }, + { label: 'uSDX BLACK_BRICK (PU7FTW)', baudRate: 38400, dataBits: 8, stopBits: 1, parity: 'none', notes: 'PU7FTW custom firmware — adds volume, attenuator, noise reduction, AGC, filter, TX drive, backlight, PA bias and S-meter controls, batched CAT polling', rigProfile: 'usdx-blackbrick' }, { label: 'Kenwood TS-480 / TS-590 / TS-2000', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', notes: 'Default 9600 8N1', rigProfile: 'generic' }, { label: 'Kenwood TS-480 (high speed)', baudRate: 57600, dataBits: 8, stopBits: 1, parity: 'none', notes: 'Configure in menu 60', rigProfile: 'generic' }, { label: 'Icom IC-7300 / IC-7610', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', notes: 'Set CI-V USB Baud Rate to 9600', rigProfile: 'generic' }, @@ -501,7 +501,7 @@ function FactoryResetButton({ onConfirm }: { onConfirm: () => void }) { // read-only reading, not a control. PA bias lives in its own on-demand panel // (PABiasPanel) behind the wrench button — not polled, queried when opened. -function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, drive, backlight, txTimeout, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onAgcLevel, onFilter, onDrive, onBacklight, onTxTimeout, onTogglePA, onReset }: { +function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, drive, backlight, txTimeout, firmwareVersion, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onAgcLevel, onFilter, onDrive, onBacklight, onTxTimeout, onTogglePA, onReset }: { volume: number | null; att1: number | null; att2: number | null; @@ -512,6 +512,7 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri drive: number | null; backlight: number | null; txTimeout: number | null; + firmwareVersion: string | null; paOpen: boolean; onVolume: (n: number) => void; onAtt1: (n: number) => void; @@ -560,6 +561,12 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri + {firmwareVersion && ( + + FW {firmwareVersion} + + )} +
@@ -754,7 +761,7 @@ const DEFAULT_CONFIG: CATConnectionConfig & { presetIdx: number } = { export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnType; collapsed?: boolean }) { const { state, connect, disconnect, setFrequency, setMode, setPTT, setVolume, setAtt1, setAtt2, setNR, setAGC, setAgcLevel, setTxTimeout, setFilter, setDrive, setBacklight, getPABias, setPABias, resetRadio, getFactoryDefaults, factoryResetRadio } = cat; - const { connected, frequency, mode, ptt, error, isSupported, volume, att1, att2, nr, agc, agcLevel, filter, sMeter, drive, backlight, txTimeout } = state; + const { connected, frequency, mode, ptt, error, isSupported, volume, att1, att2, nr, agc, agcLevel, filter, sMeter, drive, backlight, txTimeout, firmwareVersion } = state; const [showSettings, setShowSettings] = useState(false); const [showPABias, setShowPABias] = useState(false); @@ -828,7 +835,7 @@ export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnT {connected && ( <> - {config.rigProfile === 'usdx-blackbrick' && ( + {config.rigProfile === 'usdx-blackbrick' && firmwareVersion !== null && ( <>
{/* S-Meter — read-only, shown right after Connect/Disconnect */} @@ -852,10 +859,10 @@ export default function RadioCATPanel({ cat, collapsed = false }: { cat: ReturnT {/* uSDX BLACK_BRICK 4.01a extensions */} - {config.rigProfile === 'usdx-blackbrick' && !collapsed && ( + {config.rigProfile === 'usdx-blackbrick' && firmwareVersion !== null && !collapsed && ( { @@ -530,7 +537,12 @@ export function useRadioCAT(): RadioCATControls { const att1 = byPrefix.has('AT') ? parseIntField(byPrefix.get('AT')!, 'AT') : null; const att2 = byPrefix.has('A2') ? parseIntField(byPrefix.get('A2')!, 'A2') : null; const nr = byPrefix.has('NR') ? parseIntField(byPrefix.get('NR')!, 'NR') : null; - const sMeter = byPrefix.has('SM') ? parseIntField(byPrefix.get('SM')!, 'SM') : null; + // SM is special: during TX the firmware replies an empty "SM;" (nothing to + // measure — the ADC samples the mic). Frame present but valueless → reading + // is genuinely unavailable (null). Frame absent → dropped by line noise, + // keep the previous value. + const smRaw = byPrefix.get('SM') ?? null; + const sMeter = smRaw ? parseIntField(smRaw, 'SM') : null; const drive = byPrefix.has('DR') ? parseIntField(byPrefix.get('DR')!, 'DR') : null; const backlight = byPrefix.has('BL') ? parseIntField(byPrefix.get('BL')!, 'BL') : null; const agcLevel = byPrefix.has('AL') ? parseIntField(byPrefix.get('AL')!, 'AL') : null; @@ -554,7 +566,7 @@ export function useRadioCAT(): RadioCATControls { drive: drive !== null && (now - ls.drive > SET_GRACE_MS) ? drive : prev.drive, backlight: backlight !== null && (now - ls.backlight > SET_GRACE_MS) ? backlight : prev.backlight, // sMeter is read-only telemetry — no grace period needed, always take the latest poll value. - sMeter: sMeter !== null ? sMeter : prev.sMeter, + sMeter: smRaw !== null ? sMeter : prev.sMeter, })); } else { const safeQuery = async (cmd: string): Promise => { @@ -609,7 +621,7 @@ export function useRadioCAT(): RadioCATControls { setState(prev => ({ ...prev, connected: false, frequency: null, mode: null, ptt: false, error: null, volume: null, att1: null, att2: null, nr: null, agc: null, agcLevel: null, filter: null, sMeter: null, drive: null, - backlight: null, txTimeout: null, + backlight: null, txTimeout: null, firmwareVersion: null, })); }, [log]); disconnectRef.current = disconnect; // keep the read loop's teardown handle current @@ -654,6 +666,22 @@ export function useRadioCAT(): RadioCATControls { setState(prev => ({ ...prev, connected: true, error: null })); log('info', 'polling every', config.pollIntervalMs + 'ms'); schedulePoll(); + if (config.rigProfile === 'usdx-blackbrick') { + // Ask the radio which firmware it runs (FV;) — retried a few times since + // the reply can be eaten by the LCD/UART pin-share noise. The UI shows + // the PU7FTW extension controls only once this answers, so a stock rig + // on the blackbrick preset degrades gracefully to generic TS-480. + (async () => { + for (let i = 0; i < 3; i++) { + let resp = ''; + try { resp = await query('FV;'); } catch { /* retry */ } + const m = resp.match(/FV(\d\.\d\d[a-z]?);/); + if (m) { log('info', 'firmware version:', m[1]); setState(prev => ({ ...prev, firmwareVersion: m[1] })); return; } + await new Promise(r => setTimeout(r, 300)); + } + log('warn', 'radio did not answer FV; — PU7FTW extensions hidden'); + })(); + } } catch (err) { log('info', 'connection failed:', err); setState(prev => ({ diff --git a/src/lib/cat/__tests__/protocol.test.ts b/src/lib/cat/__tests__/protocol.test.ts index 13f92fc..5723baa 100644 --- a/src/lib/cat/__tests__/protocol.test.ts +++ b/src/lib/cat/__tests__/protocol.test.ts @@ -218,8 +218,15 @@ describe('response parsing — custom BLACK_BRICK commands', () => { expect(parseIntField('AL;', 'AL')).toBeNull(); }); - test('TT — TX time-out timer 0..255 s (default 180; firmware force-unkeys TX past the limit)', () => { - expect(parseIntField('TT180;', 'TT')).toBe(180); // firmware default + test('FV — firmware version string, read-only (the app gates extensions on it)', () => { + const parse = (r: string) => r.match(/FV(\d\.\d\d[a-z]?);/)?.[1] ?? null; + expect(parse('FV4.01a;')).toBe('4.01a'); + expect(parse('FV1.02;')).toBe('1.02'); + expect(parse('?;')).toBeNull(); + }); + + test('TT — TX time-out timer 0..255 s (default 30; firmware force-unkeys TX past the limit)', () => { + expect(parseIntField('TT30;', 'TT')).toBe(30); // firmware default expect(parseIntField('TT0;', 'TT')).toBe(0); // disabled expect(parseIntField('TT;', 'TT')).toBeNull(); }); @@ -240,6 +247,9 @@ describe('response parsing — custom BLACK_BRICK commands', () => { expect(parseIntField('SM-73;', 'SM')).toBe(-73); expect(parseIntField('SM0;', 'SM')).toBe(0); expect(parseIntField('SM-127;', 'SM')).toBe(-127); + // During TX the firmware replies an empty "SM;" — there is no RX signal to + // measure (the ADC samples the mic). Must parse as "no reading", and the + // frame keeps batched polls aligned. expect(parseIntField('SM;', 'SM')).toBeNull(); }); diff --git a/src/lib/cat/mockSerial.ts b/src/lib/cat/mockSerial.ts index 28e6c9f..e593765 100644 --- a/src/lib/cat/mockSerial.ts +++ b/src/lib/cat/mockSerial.ts @@ -46,7 +46,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { const rig = { frequency: opts.initialFrequency ?? 7074000, modeDigit: opts.modeDigit ?? 2, // USB - agc: 1, agcLevel: 4, txTimeout: 180, filter: 1, volume: 10, att1: 0, att2: 0, nr: 0, drive: 4, + agc: 1, agcLevel: 4, txTimeout: 30, filter: 1, volume: 10, att1: 0, att2: 0, nr: 0, drive: 4, sMeter: 7, backlight: 1, pwmMin: 10, pwmMax: 130, // PA bias endpoints (firmware defaults) @@ -132,6 +132,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { case 'A2': return `A2${rig.att2};`; case 'NR': return `NR${rig.nr};`; case 'SM': { + if (rig.tx) return 'SM;'; // no reading during TX (like the real firmware) // noisy band: random-walk the S-meter so every poll differs rig.sMeter = Math.max(0, Math.min(15, rig.sMeter + (Math.random() < 0.5 ? -1 : 1))); return `SM${rig.sMeter};`; @@ -139,6 +140,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { case 'DR': return `DR${rig.drive};`; case 'AL': return `AL${rig.agcLevel};`; case 'TT': return `TT${rig.txTimeout};`; + case 'FV': return 'FV4.01a;'; // firmware version (FV, read-only) case 'BL': return `BL${rig.backlight};`; case 'PM': return `PM${rig.pwmMin};`; case 'PX': return `PX${rig.pwmMax};`; @@ -149,7 +151,7 @@ export function createMockSerialPort(opts: MockSerialOptions = {}) { // Factory reset: acks SR2; and restores the firmware defaults (the real // radio also reboots — same "no simulated outage" caveat as SR). case 'SR2': - rig.modeDigit = 2; rig.agc = 1; rig.agcLevel = 4; rig.txTimeout = 180; rig.filter = 0; rig.volume = 11; + rig.modeDigit = 2; rig.agc = 1; rig.agcLevel = 4; rig.txTimeout = 30; rig.filter = 0; rig.volume = 11; rig.att1 = 0; rig.att2 = 0; rig.nr = 0; rig.drive = 4; rig.backlight = 1; rig.pwmMin = 10; rig.pwmMax = 130; return 'SR2;'; From d9e9017c3a132acda28af65591960fde43696065 Mon Sep 17 00:00:00 2001 From: Igor Nogueira de Oliveira Date: Mon, 6 Jul 2026 23:06:49 -0300 Subject: [PATCH 4/6] Firmware: fully suppress LCD S-meter while CAT is connected The 1Hz throttle from the previous fix was still an audible periodic tick over the shared LCD/UART pins. CAT already delivers a live reading via SM;, so the LCD field is simply not redrawn while cat_active - smode itself stays untouched, so the display resumes the moment cat_active clears. --- firmware/usdxBLACKBRICK/README.md | 7 +++++++ firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino | 18 +++++++----------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/firmware/usdxBLACKBRICK/README.md b/firmware/usdxBLACKBRICK/README.md index c991186..d802dc6 100644 --- a/firmware/usdxBLACKBRICK/README.md +++ b/firmware/usdxBLACKBRICK/README.md @@ -94,6 +94,13 @@ is a board-level design constraint, not a firmware bug. 4.01a (same day) additionally sets the PA bias max default to 130 (was 160) for this unit's PA. +A later same-day fix replaced a 1-Hz throttled LCD S-meter redraw (still an +audible periodic tick over the shared LCD/UART pins) with full suppression of +the LCD S-meter/dBm field while a CAT session is active — CAT already delivers +a live reading via `SM;`, so nothing is lost functionally, and `smode` itself +is left untouched so the LCD resumes updating immediately if `cat_active` +is ever cleared. + ## Build / flash / test See the [project README](../../README.md#usdx-black_brick-firmware) for the diff --git a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino index ac6a6c7..4b98d43 100644 --- a/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino +++ b/firmware/usdxBLACKBRICK/usdxBLACKBRICK.ino @@ -4248,17 +4248,13 @@ int16_t smeter(int16_t ref = 0) // While a CAT session is live, every LCD write is audible in the RX audio // (shared UART/LCD pins — pre()/post() mask the serial port per nibble, - // "NOISE LEAK INTO RX!!!"). The pre-2026-07-06 firmware avoided that by - // disabling the S-meter display outright on CAT connect — which froze the - // LCD value (the bug we fixed). Compromise: while cat_active, redraw at - // most once per second and only when the reading actually changed. Set - // menu S-Meter to OFF for a fully silent display. - if (cat_active) { - static int16_t dbm_drawn = INT16_MAX; - static uint32_t next_draw = 0; - if ((dbm == dbm_drawn) || (millis() < next_draw)) return dbm; - dbm_drawn = dbm; next_draw = millis() + 1000; - } + // "NOISE LEAK INTO RX!!!"). A once-per-second throttle (tried 2026-07-06) + // was still a clearly audible periodic tick, and CAT already delivers a + // live reading via `SM;` — so the LCD field just doesn't redraw at all + // while cat_active, exactly like the original firmware's intent, except + // `smode` itself is left untouched, so the display resumes the instant + // CAT disconnects (the bug we originally fixed). + if (cat_active) return dbm; lcd.noCursor(); if (smode == 1) { // dBm meter From 09310cc020eebd7a307ce9c9f36bac7eec101a83 Mon Sep 17 00:00:00 2001 From: Igor Nogueira de Oliveira Date: Tue, 7 Jul 2026 00:19:52 -0300 Subject: [PATCH 5/6] Web: move TX Timeout into advanced settings, query on-demand only TT was polled every cycle alongside the rest of the extension controls but only ever shown in the advanced settings panel - move it to the same read-on-open/write-on-commit pattern already used for PA bias (PM/PX), dropping the poll batch from 13 to 12 commands. Rename the panel from 'PA bias settings (advanced)' to 'Advanced settings' now that it holds more than PA bias. No firmware change - TT already ships on the currently-flashed 4.01a build, since the version string was never bumped for it. --- README.md | 4 +- src/components/RadioCATPanel.tsx | 73 +++++++++++++++++--------- src/hooks/useRadioCAT.ts | 58 ++++++++++++-------- src/lib/cat/__tests__/protocol.test.ts | 17 +++--- 4 files changed, 94 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 8309173..ed86f03 100644 --- a/README.md +++ b/README.md @@ -471,7 +471,7 @@ If the port is busy or silent, make sure no other CAT application (hamlib, rigct ### Custom CAT commands (PU7FTW extensions) -All commands terminate with `;`. All SET commands echo the effective value as a GET reply — if a SET is rejected (out of range), the echo returns the **old** value, which the web app relies on. Commands are safe to batch in a single write — the firmware processes them in order and concatenates replies, e.g. `FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;TT;` (the app's poll batch) returns all 13 replies in one read window. +All commands terminate with `;`. All SET commands echo the effective value as a GET reply — if a SET is rejected (out of range), the echo returns the **old** value, which the web app relies on. Commands are safe to batch in a single write — the firmware processes them in order and concatenates replies, e.g. `FA;MD;AG0;FW;VO;AT;A2;NR;SM;DR;BL;AL;` (the app's poll batch) returns all 12 replies in one read window. PA bias (`PM`/`PX`) and TX time-out (`TT`) are deliberately **not** polled — they're read/written on demand only when the advanced settings panel is open, to save the radio cycles on options that don't need live tracking. | Command | Query | Set | Range | Notes | |---------|-------|-----|-------|-------| @@ -485,7 +485,7 @@ All commands terminate with `;`. All SET commands echo the effective value as a | **FW** — filter BW | `FW;` → `FWn;` | `FWn;` | 0…7 | 0=Full 1=3k 2=2.4k 3=1.8k 4=500 5=200 6=100 7=50 Hz | | **SM** — S-meter | `SM;` → `SMn;` | — | dBm (signed) | read-only; integer-math dBm, ±1 dB vs the old float version. During TX replies an empty `SM;` (no RX signal to measure) | | **DR** — TX drive | `DR;` → `DRn;` | `DRn;` | 0…8 | pre-clipping input gain (each step doubles the envelope); 8 = always max | -| **TT** — TX time-out | `TT;` → `TTn;` | `TTn;` | 0…255 s | TOT guardrail: force-unkeys the PA past the limit; 0 = disabled, default 30 | +| **TT** — TX time-out | `TT;` → `TTn;` | `TTn;` | 0…255 s | TOT guardrail: force-unkeys the PA past the limit; 0 = disabled, default 30. Not polled — read/written on demand from the advanced settings panel | | **FV** — firmware version | `FV;` → `FV4.01a;` | — | | read-only; the web app gates its extension controls on this instead of hardcoding the version | | **PM** — PA bias min | `PM;` → `PMn;` | `PMn;` | 0…max−1 | PWM LUT idle endpoint; SET rebuilds the LUT live | | **PX** — PA bias max | `PX;` → `PXn;` | `PXn;` | min+1…255 | PWM LUT full-drive endpoint — the real output-power control for digital modes | diff --git a/src/components/RadioCATPanel.tsx b/src/components/RadioCATPanel.tsx index 314a456..3d8c62a 100644 --- a/src/components/RadioCATPanel.tsx +++ b/src/components/RadioCATPanel.tsx @@ -498,10 +498,10 @@ function FactoryResetButton({ onConfirm }: { onConfirm: () => void }) { // uSDX BLACK_BRICK 4.01a custom extension controls: volume, attenuators, noise // reduction, AGC, filter, TX drive, backlight. Wraps onto its own row below the // main toolbar. S-Meter is shown separately in the main toolbar since it's a -// read-only reading, not a control. PA bias lives in its own on-demand panel -// (PABiasPanel) behind the wrench button — not polled, queried when opened. +// read-only reading, not a control. PA bias and TX timeout live in the +// advanced-settings panel (PABiasPanel) behind the wrench button. -function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, drive, backlight, txTimeout, firmwareVersion, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onAgcLevel, onFilter, onDrive, onBacklight, onTxTimeout, onTogglePA, onReset }: { +function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, drive, backlight, firmwareVersion, paOpen, onVolume, onAtt1, onAtt2, onNR, onAGC, onAgcLevel, onFilter, onDrive, onBacklight, onTogglePA, onReset }: { volume: number | null; att1: number | null; att2: number | null; @@ -511,7 +511,6 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri filter: number | null; drive: number | null; backlight: number | null; - txTimeout: number | null; firmwareVersion: string | null; paOpen: boolean; onVolume: (n: number) => void; @@ -523,7 +522,6 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri onFilter: (n: number) => void; onDrive: (n: number) => void; onBacklight: (n: number) => void; - onTxTimeout: (n: number) => void; onTogglePA: () => void; onReset: () => void; }) { @@ -536,8 +534,6 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri - {/* TX time-out guard (TT, seconds; 0 = disabled) — firmware force-unkeys a stuck TX */} -
Auto Gain Control @@ -570,10 +566,10 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, agcLevel, filter, dri
- {/* PA bias settings — tucked away, opens the on-demand panel */} + {/* Advanced settings — tucked away, opens the on-demand panel */}