diff --git a/README.md b/README.md index fa71073..ed86f03 100644 --- a/README.md +++ b/README.md @@ -471,20 +471,33 @@ 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. 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 | |---------|-------|-----|-------|-------| | **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. 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. 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 | +| **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 +522,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 +531,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..d802dc6 --- /dev/null +++ b/firmware/usdxBLACKBRICK/README.md @@ -0,0 +1,109 @@ +# 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. + +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 +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..4b98d43 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,14 +4223,39 @@ 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; + // 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!!!"). 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 lcd.setCursor(9, 0); lcd.print((int16_t)dbm); lcd.print(F("dBm ")); @@ -4338,9 +4349,18 @@ 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 = 30; +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) { +#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. @@ -4392,6 +4412,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; @@ -4400,14 +4421,21 @@ 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 { // RX + tx_timeout_at = 0; // disarm the TX time-out guard if ((mode == CW) && (!(semi_qsk_timeout))) { #ifdef SEMI_QSK #ifdef KEYER @@ -4454,10 +4482,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 +4674,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 +4682,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 +4852,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 +4883,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 +#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, 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, 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 @@ -4877,20 +4906,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 +4924,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; @@ -4929,6 +4955,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 @@ -4949,8 +4976,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 +4997,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 +5123,11 @@ 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('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(); @@ -5165,7 +5194,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 +5469,57 @@ 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(); +} + +// 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. +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() { @@ -5511,6 +5581,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); @@ -5894,8 +5969,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) @@ -5937,6 +6011,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 @@ -6084,6 +6162,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); } @@ -6191,8 +6275,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 +6333,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 +6370,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 +6449,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 +6542,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 +6585,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 +6597,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 +6721,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 +6763,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..b1dacfe 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,50 @@ 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); +} + +// 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 +// 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 +111,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;', 'TT;']; // ── Test bed ────────────────────────────────────────────────────────────────── @@ -103,16 +138,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 +183,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,11 +193,11 @@ 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)); - 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)); } @@ -161,25 +207,25 @@ 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)); 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)); } // ── 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,11 +235,11 @@ 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)); - 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)); } @@ -201,7 +247,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)); @@ -209,17 +255,91 @@ 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)); } + // ── 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); + 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)); + } + + // ── 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 = 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)); + } + + // ── 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 = 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)); + } + + // ── 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)); @@ -227,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. @@ -246,7 +366,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)); @@ -254,26 +374,33 @@ 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. ── - 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 +408,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..72036ac 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 (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' }, @@ -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,27 +495,30 @@ 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. +// 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, 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, firmwareVersion, 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; + firmwareVersion: string | null; paOpen: boolean; onVolume: (n: number) => void; onAtt1: (n: number) => void; 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,15 +550,26 @@ function BlackBrickControls({ volume, att1, att2, nr, agc, filter, drive, backli + {/* AGC target level (AL command) — only meaningful while AGC is on */} + {agcOn && ( + + )} + + {firmwareVersion && ( + + FW {firmwareVersion} + + )} +
- {/* PA bias settings — tucked away, opens the on-demand panel */} + {/* Advanced settings — tucked away, opens the on-demand panel */}