feat(hl2): drive the HL2 IO Board over I2C2 for band-following amp control - #5362
feat(hl2): drive the HL2 IO Board over I2C2 for band-following amp control#5362randal007 wants to merge 2 commits into
Conversation
e329b3a to
3d53342
Compare
…ntrol
The Hermes-Lite 2 IO Board (N2ADR/jimahlstrom) switches amplifiers, antenna
relays and transverters from the transmit frequency. It is a plain I2C slave
at chip 0x1D on the HL2's external bus, and the gateware tells it nothing --
the host is the only party that knows where the operator is tuned. Without
these writes the board powers up and nothing follows the band, so an
amplifier stays on whatever band the previous session left it.
This adds the direct I2C-write path the backend did not have. It is separate
from the J16 open-collector filter byte, which is INDIRECT: config bits that
the gateware turns into an I2C write. This is a C&C bank naming the bus, the
chip and the register itself.
Wire format verified against four independent sources that agree:
- gateware/rtl/i2c.v: cmd_addr is 6 bits and 6'h3d selects I2C2; the Versa
and EEPROM init sequences build the same payload shape,
icmd_data_upper = {8'h06, 1'b1, 7'h6a} -- cookie, stop, 7-bit chip
- the HL2 wiki Protocol.md C&C table: C0[7]=RQST, C0[6:1]=ADDR, C0[0]=MOX,
and one-byte writes only
- deskHPSDR, a working client, which sends C0=0x7A C1=0x06 C2=0x80|0x1d
- the board's own firmware (n2adr_lib/i2c_slave_handler.c), where
REG_TX_FREQ_BYTE0 commits and the bytes are weighted <<0 .. <<32
Design decisions worth review:
- WRITE-ONLY, RQST left clear. It is optional for a write, and a reply
would reach Hl2Telemetry::apply(), which dispatches on RADDR without
consulting the ACK flag. An I2C reply lands in its default: today, but a
write that provokes no reply cannot perturb telemetry under a later edit
to that switch.
- Sent unconditionally, with no "do you have an IO board" setting, on the
same reasoning the J16 filter byte is driven blind: an absent chip NACKs
its address and the gateware's i2c_master raises missed_ack. A setting
defaulted off fails as an amplifier on the wrong band.
- Throttled to the board's documented 0.5 s, coalescing rather than
dropping. A VFO wheel delivers ~10 tune events a second; discarding them
would strand the amplifier on the old band whenever the operator stopped
turning mid-cooldown, which is when they are most likely to key.
- Sources the TRANSMIT receiver's frequency, not applyBandFilter()'s
agree-or-bypass result. The board follows where the operator radiates;
receive slices on other bands are irrelevant, and the bypass answer is a
relay pattern with no frequency to offer.
- Hooked above applyBandFilter()'s oc == m_ocFilterByte early return: the
relay pattern is unchanged from 7.100 to 7.200 MHz but the board still
needs the new frequency. Also pushed from the linkUp handler, because
applyBandFilter() does not run on the connect path -- the connect-time
filter byte is primed into MetisClient::Params instead.
- MetisClient outlives a connect (built in Hl2Backend's constructor, freed
in its destructor), so the dedupe is reset in start(). The board may have
been power-cycled between sessions and nothing can be asked what it holds.
Verified on live hardware: Hermes-Lite 2, gateware v7.5, board id 0x06, with
an N2ADR filter board and an HL2 IO Board fitted. Amplifier PTT and band
voltage both confirmed following band changes across 40m/20m/15m/10m, and the
throttle observed coalescing a VFO sweep to one push per 500 ms while a band
change still fired on the leading edge with no added latency.
Unit tests cover the C0/C1/C2 encoding, 7-bit chip-address masking, the
big-endian byte split, LSB-last ordering, a round trip through the firmware's
own assembly expression, and that all 40 bits reach the wire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3d53342 to
419752c
Compare
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
No linked issue; reviewed against stated intent. The feature is real and the protocol work is careful: the HL2 IO board needs the host to push TX frequency over I2C2, AetherSDR had no I2C write path, and this adds one. The wire derivation is grounded in the gateware RTL (i2c.v's 6-bit cmd_addr, 6'h3d = I2C2, the {cookie, stop, 7-bit addr} payload shape), cross-checked against the HL2 wiki C&C table, a working client (deskHPSDR), and the board firmware — exactly the HL2-gateware-is-authority discipline this project requires, and the ccIoBoardTxFrequency encoder's endianness comment plus the byte-layout test pin the part most likely to be silently wrong. Under Constitution Principle VI this is receive-side/relay control that never keys TX, which is correct.
The blockers are all in the scheduling wrapper around that solid encoder — the leading/trailing-edge throttle and its lifecycle — and matter because the thing being scheduled points an amplifier at a band.
Scope
Seven files, all explained by the feature: protocol encoder + test, MetisClient queue path, backend scheduling. No CI has run yet (no checks reported) — that must be green before merge. Preflight: no sockets, no fake peers; the test is a pure encoder test.
Blockers
1. The leading-edge push has no !m_connected guard the trailing edge has (inline). At Hl2Backend.cpp:5015 the immediate setIoBoardTxFrequencyHz invoke runs whenever the throttle is idle — but a tune while disconnected reaches applyIoBoardFrequency (setSliceFrequency/applyBandFilter aren't m_connected-gated and m_rx survives linkDown), so five IO-board banks queue into MetisClient::m_oneShot, which start()/stop() never clear. On reconnect they drain before the connect-time push — momentarily pointing an amplifier at the previous session's band. This is the exact hazard the timer branch documents and guards at :5020; the leading edge must carry the same if (!m_connected) { m_pendingIoBoardHz = 0; return; }.
2. A mid-sweep band jump lags the filter by up to 500 ms (inline). During continuous VFO tuning the throttle stays active, so a 40m→20m crossing mid-sweep only sets m_pendingIoBoardHz while applyBandFilter switches the relay immediately — filter and amplifier disagree for up to 500 ms, and keying in that window strands the amp on the old band. The body claims "the leading edge applies immediately so a band change has no added latency"; that holds only at a sweep's end, not across a band edge within one. Apply on the leading edge when the band (not just the frequency) changes, even while the throttle is active — the amplifier's band is the safety-relevant quantity, distinct from frequency-tracking rate-limiting.
3. m_ioBoardThrottle is not stopped/reset on linkDown (inline at the linkDown handler). Its isActive/pending state leaks across a disconnect, so a reconnect inside the residual window stores the connect-time frequency as pending instead of pushing it on the leading edge — the board's frequency is delayed up to ~500 ms exactly when the connect path intends an immediate push. Stop the timer and clear m_pendingIoBoardHz alongside m_linkStatsTimer in the linkDown path.
Nits (non-blocking)
applyIoBoardFrequency()runs beforeapplyBandFilter'soc == m_ocFilterBytechange-gate, so every micro-tune within a band re-arms the QTimer and marshals a cross-threadQueuedConnectiononly forMetisClientto dedupe it away. A backend-side compare oftargetto the last scheduled value before touching the timer avoids the repeated scheduling (this also naturally subsumes part of blocker 2's fix if keyed on band).- The
m_oneShotqueue growing on a disconnected backend (blocker 1's mechanism) is worth its own guard inMetisClient::setIoBoardTxFrequencyHz— refuse to queue when not streaming — as defense in depth even after the backend guard lands.
What was verified vs read
- Verified by me in the PR head: the leading-edge push lacks the
!m_connectedguard the trailing branch carries (both branches read directly); theapplyIoBoardFrequency-before-change-gate ordering; the endianness encoder against its own comment and the test's byte expectations. - Wire format: trusted the body's four-source derivation — the gateware RTL is not vendored in this checkout, so I could not re-grep
i2c.vdirectly, but the derivation is internally consistent, matches deskHPSDR's constants, and the project's authority rule points at exactly those sources. Flagging that I confirmed the shape from the citation, not from the RTL in-tree. - Not run: no hardware (no IO board, and TX-adjacent — though this path never keys), no bridge session; CI has not run at all, which is itself a merge gate.
| m_pendingIoBoardHz = target; // superseded by any later request | ||
| return; | ||
| } | ||
|
|
There was a problem hiding this comment.
Blocker 1 — leading edge lacks the !m_connected guard the trailing edge has. This immediate invoke runs whenever the throttle is idle, but applyIoBoardFrequency is reachable while disconnected (setSliceFrequency/applyBandFilter aren't m_connected-gated, m_rx survives linkDown), so five banks queue into MetisClient::m_oneShot (which start()/stop() never clear) and drain at the next connect — pointing an amplifier at the prior session's band. The timer branch at :5020 already guards this; mirror it:
| if (!m_connected) { | |
| m_pendingIoBoardHz = 0; | |
| return; | |
| } | |
| QMetaObject::invokeMethod(m_metis, "setIoBoardTxFrequencyHz", | |
| Qt::QueuedConnection, Q_ARG(quint64, target)); | |
| m_ioBoardThrottle->start(); |
| }); | ||
| } | ||
|
|
||
| if (m_ioBoardThrottle->isActive()) { |
There was a problem hiding this comment.
Blocker 2 — a mid-sweep band jump lags the amplifier up to 500 ms. While tuning keeps the throttle active, a 40m→20m crossing only sets m_pendingIoBoardHz, but applyBandFilter switches the relay at once — filter and amp disagree for the throttle window, and keying there strands the amp on the old band. The body's "no added latency" holds only at a sweep's end. Push immediately when the band changes even while throttled (rate-limit frequency tracking, not band changes); the amp's band is the safety-relevant quantity.
| // MetisClient::Params instead — so without this the board would hold | ||
| // whatever the last session left it, and an amplifier would stay on that | ||
| // band until the operator's first retune. Placed after pushInitialState() | ||
| // so the receiver frequencies it reads are the restored ones. |
There was a problem hiding this comment.
Blocker 3 — m_ioBoardThrottle isn't reset on linkDown. This handler stops m_linkStatsTimer but leaves the IO-board throttle armed with a pending value, so a reconnect inside the residual window stores the connect-time frequency as pending instead of pushing it on the leading edge — delaying the board exactly when connect intends immediacy. Add m_ioBoardThrottle->stop(); m_pendingIoBoardHz = 0; here.
ten9876
left a comment
There was a problem hiding this comment.
Issue fit
No linked issue. The problem is real and clearly stated: the HL2 IO Board (N2ADR) switches amps/relays/transverters by transmit frequency but the gateware never tells it where the operator is tuned, and AetherSDR had no direct I2C write path — so an IO-board owner got a board that never follows the band. The wire-format derivation is careful and unusually well-sourced (gateware RTL, HL2 wiki, deskHPSDR, board firmware, all cross-agreeing), the encoding respects the HL2's one-byte-write / five-bank constraint, and the true-RF-not-NCO-scaled distinction is exactly right. This is good work.
Three things gate it: one confirmed correctness bug, one hardware-safety question only you can rule on, and the governance shape of adding a new external-hardware write path with no issue.
Scope
Seven files, coherent (protocol + backend + throttle + test). No CHANGELOG.md. Preflight: no sockets, no fake peers — hl2_metis_protocol_test is a pure encoder round-trip.
Blockers
1. The leading-edge send lacks the !m_connected guard the timeout path has (inline). applyIoBoardFrequency() is reached from applyBandFilter (tune/slice/pan changes), gated only by if (m_metis) — which is non-null whenever the client object exists, not only when connected. A tune while disconnected takes the leading-edge branch (throttle inactive), QueuedConnection-invokes setIoBoardTxFrequencyHz, and queues five IO-board banks onto m_oneShot. MetisClient::stop() does not clear m_oneShot and the IO-board frequency (unlike the filter byte) is not re-primed via Params, so those stale banks drain first on the next connect — briefly pointing an amplifier at the band the previous session ended on. You already guard exactly this in the timeout branch, with a comment naming the m_oneShot-not-cleared hazard; the leading-edge send needs the same if (!m_connected) return;.
Needs your ruling (not a code nit)
2. No guard against an IO-board relay write while transmitting — a hot-switch hazard. Nothing in applyIoBoardFrequency/setIoBoardTxFrequencyHz suppresses the write when m_mox is set. On HL2 the operator usually can't retune mid-transmit, so this may be unreachable in practice — but band-following writes on linkUp or via automation could in principle land while keyed, and switching an amp relay under RF is destructive. This is amp hardware and Principle-VI-adjacent (the spirit of "never automate into a keyed transmitter"), so it wants your explicit sign-off: does the board latch relay changes until unkey, or should AetherSDR gate IO-board updates on !m_mox?
3. New external-hardware write path, no issue/RFC. GOVERNANCE.md treats a new feature — and this adds a whole new I2C-write surface to third-party hardware — as wanting an issue or maintainer nod first. Routing to you as the Needs maintainer decision this is, rather than waving it through.
Nits (non-blocking)
kIoBoardTxFreqBanks(5),kIoBoardRegTxFreqMsb(0),kIoBoardRegTxFreqLsb(4) are three uncoupled constants the loop assumes agree:shift = 8 * (kIoBoardRegTxFreqLsb - reg)overkIoBoardTxFreqBanksiterations. Bump banks to 6 without touching Lsb andreg=5underflows theuint8_tsubtraction to 255 →8*255shift, UB / garbage byte on the wire. Astatic_asserttyingkIoBoardTxFreqBanks == kIoBoardRegTxFreqLsb - kIoBoardRegTxFreqMsb + 1closes it.m_ioBoardThrottle(lazy singleShot + pending-value + coalesce/re-arm) is a near-verbatim copy ofm_bandwidthThrottle; a sharedcoalesceThrottle(QTimer*&, pending, applyFn)would stop the two drifting (blocker 1 is exactly the kind of fix that would need applying to both).
What was verified vs read
- Verified by me in the PR head: the asymmetric
!m_connectedguard (timeout path has it, leading-edge send at line 5016 does not, andapplyIoBoardFrequencyis reachable while disconnected viam_metis-only gating); the absence of any TX-active guard on the relay write; the register-constant coupling. - Could not independently verify — stated plainly: the wire format itself. The HL2 gateware RTL is not vendored in this repo (
gateware/rtl/i2c.vis absent), so I cannot check6'h3d/the{0x06, stop, chip}payload against the Principle-I authority directly. The PR's four cross-agreeing sources are strong provenance, and the new test round-trips byte order / chip-mask / 40-bit range — but that pins internal consistency against the PR's own encoder, not the external wire truth. A maintainer with gateware access (or the live board) is the real confirmation. - No bridge session — HL2 IO-board hardware, unreachable from the demo.
Recommendation: needs maintainer decision — the correctness bug (blocker 1) is a clean fix, but the TX-hot-switch safety question and the new-hardware-surface governance call are yours, and the wire format wants a gateware-authoritative confirm before it drives real relays.
| return; | ||
| } | ||
|
|
||
| QMetaObject::invokeMethod(m_metis, "setIoBoardTxFrequencyHz", |
There was a problem hiding this comment.
Blocker — this leading-edge send has no !m_connected guard, unlike the timeout path. A tune while disconnected reaches here (applyIoBoardFrequency is gated only by if (m_metis), non-null whenever the client object exists), fires this invoke, and queues five IO-board banks onto m_oneShot — which stop() does not clear and which are not re-primed via Params — so they drain first on the next connect, pointing an amp at the previous session's band. Your own timeout branch guards exactly this with a comment. Mirror it:
| QMetaObject::invokeMethod(m_metis, "setIoBoardTxFrequencyHz", | |
| if (!m_connected) | |
| return; // see the timeout branch: m_oneShot survives stop() | |
| QMetaObject::invokeMethod(m_metis, "setIoBoardTxFrequencyHz", | |
| Qt::QueuedConnection, Q_ARG(quint64, target)); |
All four blockers were in the scheduling wrapper, not the encoder, which is
where the review said to look. Rather than patch each condition at its call
site, the decision now lives in one pure policy the suite can exercise
without a radio (Hl2IoBoardPolicy.h) — the defects were conditions, and
conditions are only testable if a test can reach them.
Blocker 1 — leading edge lacked the !m_connected guard the trailing edge
had. Both edges now go through sendIoBoardFrequency(), so the asymmetry
cannot return: two call sites that must agree about a hardware safety
condition was one call site too many. MetisClient::setIoBoardTxFrequencyHz
additionally refuses to queue while !m_running, as defence in depth at the
wire itself.
Blocker 2 — a band change mid-sweep no longer waits for the cooldown. The
throttle exists for VFO sweeps (~10 events/second against a board that asks
for two); a band crossing is not that case, because applyBandFilter moves
the physical filter relay at once and deferring the amplifier leaves the two
disagreeing for up to 500 ms. Frequency tracking is rate-limited; which band
the amplifier is on is not. Tracked with m_ioBoardBandKey, keyed off the
same bandKeyForHz() table the per-band memory uses.
Blocker 3 — resetIoBoardSchedule() drops the armed timer, the coalesced
value and the remembered band on linkDown and on connectFailed. Left
running, a reconnect inside the residual window stored the connect-time
frequency as pending instead of pushing it, delaying the board exactly when
linkUp() intends immediacy. Clearing the band key also makes the first push
of any session count as a band change, so it takes the leading edge.
Maintainer question — never write while keyed: ruled by the operator, whose
amplifier this is. Switching a band relay under RF burns the contacts, and
this write is what moves it. The window is narrow on this radio but not
closed (connect-time pushes and the automation bridge can both reach the
scheduler with MOX up), and the cost of being wrong is damaged hardware, so
the guard is unconditional rather than reasoned about per caller. Deferring
costs nothing: unkey calls applyBandFilter("unkey"), which re-runs the path
and recomputes from the TX receiver — fresher than any stashed value.
Nit — static_assert ties kIoBoardTxFreqBanks to the Msb..Lsb register span.
Raising the bank count alone would underflow the unsigned shift arithmetic
and put a garbage byte on a wire that drives a band relay; that edit now
fails to compile.
Nit not taken — the shared coalesceThrottle() refactor with
m_bandwidthThrottle. The two now differ in ways that are load-bearing here
(the band-change override and the keyed interlock have no bandwidth
analogue), so folding them together would either genericise those away or
push HL2 transmit semantics into a display-rate helper. Happy to revisit if
you would rather have the shared shape.
hl2_io_board_policy_test covers all sixteen condition combinations, with the
reviewed regression — disconnected, idle throttle, band changed, i.e. the
leading edge — pinned by name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — the review was right on every count, and all four are addressed in Your ruling question: never write while keyedAnswer: gate it. I own the amplifier in question and I'd rather the software never move a band relay under RF, regardless of how narrow the window is. Implemented as an unconditional To be explicit about what I have not established: I don't know whether the N2ADR board latches relay changes until unkey. I chose not to depend on finding out. Blockers1 — leading edge missing the guard. Fixed, but not by copying the guard: both edges now route through 2 — band change lagging the filter. Fixed. Frequency tracking stays rate-limited; the band does not. Tracked via 3 — throttle surviving linkDown. Fixed via Structural changeThe four fixes are all conditions, and conditions aren't testable if they only exist inside a
Nit not takenThe shared On the wire format you couldn't verifyEntirely fair, and worth stating plainly rather than leaving to the four-source citation: this is running on my own HL2 (gateware v7.5, board id GovernanceNoted on the missing issue for a new external-hardware write path — that was my error in shape, not just paperwork. Happy to open one retroactively and link it if you'd like the trail, or to fold this under a broader HL2-companion-hardware issue if you'd prefer one umbrella. 🤖 Generated with Claude Code |
|
Bench verification of Posting this because the review noted, fairly, that the wire format could not be checked against the gateware in-tree. It does not replace that — it is one board and one firmware — but the three fixed behaviours are now observed rather than argued. Blocker 2 — band change no longer waits for the cooldown. Filter and amplifier switch in the same millisecond, twice, in both directions: Throttle still rate-limits same-band movement, so the band-change override did not simply disable it — a continuous VFO sweep inside 40 m: The keyed interlock, exercised for real. A band change initiated during TUNE — the write is withheld while keyed and lands on unkey, in the same millisecond, carrying the recomputed frequency rather than a stashed one: The amplifier's band relay did not move under RF. Amplifier PTT and band voltage follow band changes as before, so the interlock did not cost the feature anything. Unrelated observation, not a request to change anything here. In that last trace the filter board relay does switch while keyed ( 🤖 Generated with Claude Code |
Correcting my own ruling on the keyed interlockI answered your question 2 with "gate it — never write while keyed," and you built the review outcome on that answer. Bench testing since has shown the answer was wrong, and the PR as it currently stands carries the hazard without the remedy. Flagging before you spend more review time on it. What the interlock actually does on hardware. A band change initiated during TUNE, from the operator's log: The interlock worked exactly as specified. The problem is what "as specified" means in context: That is worse than the hazard I was guarding against. I was reasoning about relay contact wear — cumulative, gradual, survivable — and traded it for driving an amplifier into a severe mismatch, which is neither. Why the amplifier cannot sequence around it. The operator's amplifier (Xiegu XPA125B) takes band voltage and PTT as dumb signals with no logic of its own — it acts the instant it sees a change. There is no third party in the chain that can order these events, which is what makes this the client's problem rather than the operator's wiring. Recommendation for this PR: drop the interlock. Send the band change immediately, as the pre-review code did. That eliminates the wrong-band window, keeps this PR to what its title says, and leaves the sequencing question where it belongs. I'd rather hand you a narrow correct change than a wide one built on my mistake. If you'd prefer to keep the interlock, it should not land without something that stops the transmission first — otherwise it converts a millisecond hazard into a multi-second one. The sequencing question, separatelyThe correct fix is that neither relay should move under RF and the amplifier should never be driven off-band — which means the transmission has to end before the band does. Unkey only, never re-key: This stays inside Principle VI as I read it: it only ever drops MOX. Re-asserting it afterwards would be the client keying as a side effect of a model change, which is precisely what VI forbids — so it does not, and the operator re-keys deliberately. I have this implemented and running on the operator's radio (41 lines in Happy to open it as an issue with the bench traces, or to drop it entirely if you consider the scenario out of scope — a band change mid-transmission is arguably operator error, and "don't do that" is a legitimate answer. Your call. Status otherwiseCI is now green on this branch (build / static checks / windows / macOS). The three blockers from your review are addressed in 🤖 Generated with Claude Code |
jensenpat
left a comment
There was a problem hiding this comment.
Reviewed exact head 93e730716cf43080bd4d6b7f90b23bb997f839fc against current origin/main b149933450c0779e4462fb22a808c05c4ac308ed.
What is clean
- The protocol encoder is consistent with the tier-1 gateware (
i2c.v:6'h3d, cookie/stop/chip payload), the HL2 wiki register map, and Quisk's MSB-first register 0..4 writes. The two commits have valid signatures. - The changed tests are socket-free; there is no fake peer or bound endpoint. CI's build, static, Windows, and macOS checks are green.
git merge-tree --write-tree origin/main HEADis clean.
Blocker — keyed deferral can transmit through a wrong-band amplifier
IoBoardAction::DeferKeyed returns before updating the external IO board, but applyBandFilter() continues changing the HL2-side transmit path. The author's latest bench trace confirms the result: the radio transmitted on 14.225 MHz for 4.4 seconds while the amplifier remained set to 40 m. That trades a possible hot-switch for an immediate wrong-band/mismatch condition and cannot merge.
The author has correctly recommended dropping this interlock from this PR so the amplifier follows the actual transmit band immediately. The more complete alternative—unkey before changing the NCO/HL2 filter/IO-board relay, never auto-rekey—changes keying behavior and should be a separately discussed issue/PR with maintainer approval.
Runtime coverage hole
The timer callback at Hl2Backend.cpp:4997-5005 bypasses ioBoardAction() and calls sendIoBoardFrequency() directly. If a same-band tune coalesces a pending value and MOX/TUNE starts before the 500 ms timeout, the pending write still fires while keyed because sendIoBoardFrequency() only checks m_connected. The new pure policy test passes all 16 boolean combinations without exercising this runtime path, so its keyed-safety claim is stronger than the implementation.
If the interlock is retained, the timeout must re-evaluate the live keyed state (and have runtime coverage). If it is removed as the author recommends, remove the keyed-policy claims/tests so the code and evidence state the actual chosen behavior.
No local build/full suite was run after confirming this hardware-safety blocker; CI is green, but further expensive validation would not make the current revision mergeable. Four existing review threads also remain unresolved.
The problem
The HL2 IO Board (N2ADR) is a Raspberry Pi Pico that switches amplifiers, antenna relays and transverters according to the transmit frequency. It sits on the HL2's external I2C bus as a plain slave at chip
0x1D.The gateware tells it nothing. The board cannot observe the radio's tuning — the host is the only party that knows where the operator is, and the board's documentation is explicit that "SDR software must send the transmit frequency."
AetherSDR has no direct I2C write path at all. The only I2C-adjacent code is the J16 open-collector filter byte, which is indirect: config-register bits that the gateware turns into an I2C write on the host's behalf. So today an HL2 owner with an IO board gets a board that powers up and does nothing that follows the band, and an amplifier that stays wherever the previous session left it.
Wire format
The HL2 exposes its two I2C buses as C&C addresses
0x3c(I2C1, internal) and0x3d(I2C2, the external companion-board bus).The gateware emits
{C3, C4}as a two-byte I2C write. One-byte writes only — no burst mode, so the 40-bit frequency costs five C&C banks.Verified against four independent sources that agree:
gateware/rtl/i2c.v)cmd_addris 6 bits and6'h3dselects I2C2. The Versa and EEPROM init sequences build the identical payload shape:icmd_data_upper = {8'h06, 1'b1, 7'h6a}— cookie, stop bit, 7-bit chip address.Protocol.mdC0[7]=RQST,C0[6:1]=ADDR,C0[0]=MOX. Confirms one-byte writes and that RQST is optional for a write.C0=0x7A,C1=0x06,C2=0x80|0x1d— matches the derivation exactly, against the same board.n2adr_lib/i2c_slave_handler.c)REG_TX_FREQ_BYTE0is the commit trigger; bytes weighted<<0through<<32.The firmware's assembly expression fixes both the byte order and the ordering constraint:
Two consequences: the LSB must be written last, and all five bytes must be written even though the top one is always zero on HF — the board keeps the others in its own register file, so an omitted byte silently contributes a stale value from the previous commit.
Design decisions
Write-only — RQST deliberately left clear. RQST is optional for a write, and setting it makes the radio answer with an ACK response.
Hl2Telemetry::apply()dispatches on RADDR without consulting the ACK flag. An I2C reply (RADDR0x3d) lands harmlessly in itsdefault:today, but a write that provokes no reply cannot perturb the telemetry decoder under any later edit to that switch.Sent unconditionally, no enable setting. Same reasoning the J16 filter byte is already driven blind: a chip that is not on the bus NACKs its address, and the gateware's
i2c_masterraisesmissed_ackand moves on. Driving it costs an absent board nothing, and a setting defaulted off fails as an amplifier on the wrong band.Throttled to 0.5 s, coalescing rather than dropping. The board's README asks for at most one update every 0.5 s, and only on change. A VFO wheel delivers ~10 tune events a second; dropping those inside the window would strand the amplifier on the old band whenever the operator stopped turning mid-cooldown — precisely when they are most likely to key. Leading edge applies immediately so a band change has no added latency.
Sources the transmit receiver's frequency, not
applyBandFilter()'s agree-or-bypass result. The board follows where the operator will radiate; receive slices on other bands are irrelevant, and the bypass answer is a relay pattern with no frequency to offer.Hooked above the filter-byte early return.
applyBandFilter()returns early whenoc == m_ocFilterByte. The relay pattern is unchanged from 7.100 to 7.200 MHz, but the board still needs the new frequency.Also pushed from the
linkUphandler.applyBandFilter()does not run on the connect path — the connect-time filter byte is primed straight intoMetisClient::Params. Without an explicit push atlinkUp, the board would hold whatever the previous session left it until the first retune.Dedupe reset in
MetisClient::start().MetisClientoutlives a connect (built inHl2Backend's constructor, freed in its destructor). Without the reset the dedupe would carry a frequency across a disconnect and suppress the first push of the next session, against a board that may have been power-cycled in between.Hardware verification
Confirmed on live hardware: Hermes-Lite 2, gateware v7.5 (0x4b), board id 0x06, with an N2ADR filter board and an HL2 IO Board fitted, over a direct link-local Ethernet connection.
Amplifier PTT and band voltage both follow band changes, confirmed across 40 m / 20 m / 15 m / 10 m.
Connect-time and tune-time pushes:
The throttle under a live VFO sweep — coalesced to one push per 500 ms carrying the latest value, while a band change fires on the leading edge in the same millisecond as the filter byte:
Tests
New cases in
hl2_metis_protocol_test(standalone — no Qt, no aethercore):HL2 suite: 22 of 23 targets pass.
One pre-existing failure, unrelated to this change:
hl2_state_restore_testfails three CW passband guard cases (a pre-#4914 CW passband is dropped…and two siblings). Confirmed identical with this patch reverted on the same tree and toolchain, so it is reported as an observation rather than something this PR addresses.Open questions
Board detection. deskHPSDR gates its writes behind a detection read — chip
0x41, expecting0xF1. This patch does not, on the "inert when absent" reasoning above. Detection would need the RQST/ACK read path, and would mean routing ACK responses with RADDR0x3c/0x3daway fromHl2Telemetry::apply(), which currently switches on RADDR without checking the ACK flag. That felt like a larger and riskier change than the feature warranted, but it is the natural follow-up and would let the UI report the board's firmware version. Happy to take direction here.Optional registers not implemented. The board also accepts the twelve receive frequency codes (
REG_FCODE_RX1…RX12), an RF-input mode register, fan speed, and the Icom AH4 antenna-tuner protocol on register 7. Only the transmit frequency is sent here, since that is what the board's documentation calls the one required piece. The AH4 register would be the concrete path to #542.Upstream's stated position. The IO board's README says "Do NOT ask authors to modify SDR software! Write new firmware instead!" That is aimed at per-installation behaviour — which pins to switch, which amplifier to key — and this patch encodes none of it. It sends the frequency the board's own contract asks the host for and leaves all policy to board firmware, which is what makes custom firmware possible in the first place.
Separately: a packaging bug (not part of this PR)
cmake --installon Linux with ONNX enabled produces a binary that cannot start. The install step strips RPATH (Set non-toolchain portion of runtime path to "") but the install rules never copylibonnxruntime.so, so the installed binary fails witherror while loading shared libraries: libonnxruntime.so.1. The build-tree binary is fine because it retains an RPATH intothird_party/onnxruntime/lib. Worked around locally withCMAKE_INSTALL_RPATH='$ORIGIN/../lib'plus copying the libraries. Happy to file this separately if useful.🤖 Generated with Claude Code