Skip to content

Add Hermes-Lite 2 Plus (AK4951 companion board) support - #6

Open
randal007 wants to merge 2 commits into
Zeus-SDR:mainfrom
randal007:hl2-plus
Open

Add Hermes-Lite 2 Plus (AK4951 companion board) support#6
randal007 wants to merge 2 commits into
Zeus-SDR:mainfrom
randal007:hl2-plus

Conversation

@randal007

Copy link
Copy Markdown

Support for the Hermes-Lite 2 Plus — an HL2 carrying the AK4951 companion board, which adds a stream codec with microphone input, headphone and speaker outputs, and CW sidetone.

Developed and tested on real hardware over a full session: headphone audio, the radio's own microphone, and the CW keyer with sidetone all work, alongside an N2ADR IO board in the same radio.

Stacked on #4 (HL2 IO board), so that commit appears here too. Happy to rebase once #4 lands or is declined.

The board cannot be detected, so the operator declares it

An HL2+ answers discovery exactly like a stock HL2 — no board ID, no gateware marker, nothing to probe. So this follows the existing precedent for an operator-selected hardware fact (OrionMkIIVariant) and lets the declaration promote the capability fingerprint:

For(HermesLite2, variant, hl2PlusCodec: true)
    => caps with { HasOnboardCodec = true, HasMicBias = true }

Promoting the capability rather than special-casing each consumer is the design. RadioSpeakerAudioSink, the Protocol-1 mic attach, ClampAudioSource and the frontend already gate on HasOnboardCodec, so one lever lights all of them, and almost no new audio plumbing was required — ControlFrame already writes the EP2 L/R slots, PacketParser already decodes the EP6 mic slots.

The corollary is worth stating for review, because it cost three separate faults during bring-up: every capability lookup that feeds a gate or a clamp must read the promoted set. The worst was PushAudioFrontEnd, which read the unpromoted table, so ClampAudioSource sent the operator's Radio Mic selection back to Host on the way to the pipeline — while the store, /api/radio/audio and /api/radio/capabilities all went on reporting RadioMic, because those read the promoted set. Symptom: a mic that is correctly configured everywhere you can look and silent on the air.

HasRadioLineIn and HasBalancedXlr stay false — the board has neither jack, so those options stay hidden.

Config C3 bit 3

The HL2+ gateware reads C3 bit 3 as "an audio codec is present". mi0bot's HL2 fork reads the same bit as the Band Volts PWM enable, which is what Zeus drives it as today. deskHPSDR sets the identical bit for the codec via its LT2208_DITHER_ON alias. One bit, two incompatible gateware meanings, so the two features are made mutually exclusive:

  • declaring HL2+ force-disables Band Volts;
  • arming Band Volts is refused while HL2+ is declared;
  • /api/radio/hl2-options reports bandVoltsAvailable, so a UI can disable the control rather than let a PUT be silently ignored.

Confirmed by the vendor's own FAQ, which describes that same Thetis "Band Volts/Dither" checkbox as the control for the companion board's speaker output.

CW sidetone

Host-generated, carried to the codec on the EP2 L/R slots while keyed, following deskHPSDR's approach.

This was not the first design. The gateware advertises its own headphone sidetone and accepts internal_CW and a non-zero sidetone_level/api/diagnostics/cw-wire confirms Zeus sends both correctly — but on this hardware it never sounds. Removing the host injection silences CW entirely, which is how we established it. The host's own monitor tone is therefore suppressed while the codec carries one (HardwareSidetoneOwnsMonitor reading the promoted capability), so the operator hears a single in-time tone from the radio instead of a second, late one from the PC's speakers.

The keyer's RF_delay also now carries piHPSDR's value rather than zero. piHPSDR documents it as "a quirk working around a bug in the FPGA iambic keyer", and parity with a client known to drive this board seemed worth more than our previous reasoning that Zeus owns key-to-RF timing host-side.

Radio-speaker ring: board-agnostic fixes

These benefit any Protocol-1 codec board, not just this one:

  • Priming. The EP2 packer asked for audio the instant a stream came up, long before the DSP had produced any, so every early read came back short. Measured on an HL2+: 252,186 underrun samples — 5.3 seconds of gaps — all at startup, heard as a run of pops for the first half-minute. The ring now banks ~21 ms before serving, which took that to 17,388 and none afterwards.
  • Rate correction. Capacity did not bound the monitor's delay: the producer wrote 48,016 samples/sec while the packer drained 47,861, so the ring crept up until it sat permanently full — a third of a second of latency, with drop-oldest discarding a chunk on every overflow. It now shaves at most 8 samples per write (0.17 ms, inaudible) toward a 3072-sample target.

Diagnostics

Three read-only endpoints, each of which located a fault that reading the code had not:

Endpoint Answers
/api/diagnostics/cw-wire what the CW frames actually carry — separates "Zeus never sent it" from "the radio ignored it"
/api/diagnostics/rx-audio speaker-ring latency, underruns, drops, trims
/api/diagnostics/radio-mic every link in the mic chain: ingest, receiver, sink, attach latch, active source, handler

The mic bug above was found in one call by radio-mic, which showed a store saying RadioMic next to a pipeline saying Host. Three prior attempts to find it by reading the code were all wrong.

Routing the HL2+ microphone

For anyone bringing one of these up, the working sequence — and the traps, each of which cost real time:

  1. Declare the board. PUT /api/radio/hl2-options {"bandVolts":false,"hl2Plus":true}. Persists; re-pushed on connect.
  2. Select the radio's mic. PUT /api/radio/audio {"source":"RadioMic"}. Single-select — arming the radio drops the host mic instantly, and {"source":"Host"} returns to the computer's. This is the Zeus equivalent of turning VAC1 off in Thetis, which is required there for the same reason.
  3. Confirm it is actually routed, not merely configured: /api/diagnostics/radio-mic should show activeSource: RadioMic, p1MicAttached: true, handlerAttached: true, and samplesAccepted climbing at ~48,000/sec.

Hardware traps worth documenting, since neither is a software fault:

  • The paddle goes in the KEY jack, not the MIC jack. In the mic jack the tip trips PTT and the ring does nothing, which looks exactly like a broken iambic keyer.
  • The FPGA keyer only arms in CW mode — the internal_CW bit is derived from the operating mode, not the keyer setting — so nothing CW-related works in USB.

Known limitation

Faint crackle and pops remain during CW, and there is room for improvement here. They are much reduced — the startup burst is gone, and the ring measures clean in steady state (trimmed and underruns flat, latency ~30 ms) — but not eliminated.

The likely cause is inherent to the current sidetone design: carrying host sidetone to the codec means the codec's audio switches content on every element, band RX between dits and sidetone during them. Thetis, which is clean on identical hardware, avoids the analogous problem on its VAC path with a feedback-controlled variable-ratio resampler servoing the buffer to 50% occupancy, rather than the sample-shaving used here. That is the shape of a proper fix and is deliberately not attempted in this PR.

Compatibility

Off by default. A radio whose operator has not declared a companion board keeps the stock HL2 fingerprint and byte-identical wire behaviour. Hl2Plus on the set request is nullable, matching IoBoard, so a client PUTing only {bandVolts} cannot silently un-declare someone's companion board.

Note for reviewers

The HL2+ gateware is not open source, which is why deskHPSDR's author declines to support the board and is a reasonable reason for this project to decline it too. Nothing here depends on that gateware being open — the wire side is ordinary Protocol-1 audio slots plus one Config bit — but the feature is only exercisable by operators running it. The ring priming, the rate correction and the diagnostics stand on their own and apply to every Protocol-1 codec board, if those are worth taking separately.


Authorship disclosure: this patch was written by Claude (Anthropic's AI) working with the station owner, who owns the HL2+ and IO board hardware, loaded the companion gateware, and did all on-air testing. The reasoning and provenance comments are the AI's; every measurement quoted above came from the operator's bench.

🤖 Generated with Claude Code

The HL2 IO board (jimahlstrom/HL2IOBoard) is a Pico daughter board on the
filter-board header. It has no HPSDR knowledge: the PC pushes the transmit
frequency, RF-input routing and receive frequency codes into its I2C
register file, and Pico firmware drives amplifier, antenna, transverter,
fan and AH-4 tuner lines from them. Zeus had no I2C path at all, so this
adds one.

Wire layer (Zeus.Protocol1):

- Hl2IoBoard.cs — register map mirroring the firmware's i2c_registers.h,
  the tunnelled-transaction encoding (0x7A write / 0xFA read on I2C-2 at
  0x1D), and a pure scheduler: detect via the hard-wired PCA9536D at 0x41,
  then cycle TX frequency, RF inputs and the RX frequency codes.
- ControlFrame — a raw I2C control block may displace one rotation slot in
  the odd USB frame, so no extra EP2 packet is introduced and the TX FIFO
  cadence the transmitter depends on is untouched.
- PacketParser.TryExtractHl2I2cReply — decode replies, which the HL2
  echoes on C&C address 0x3D.
- Protocol1Client ticks the scheduler on the EP2 loop and feeds replies
  back, gated on BoardKind and the operator's switch.

Hosting:

- PreferredRadioStore / RadioService persist the switch alongside Band
  Volts and re-push it into each fresh client on connect.
- /api/radio/hl2-options gains `ioBoard` plus a read-only `ioBoardPresent`
  so the UI can tell "switched on" from "switched on and answering".
  `IoBoard` on the PUT request is nullable — a client that sends only
  BandVolts must not switch the board off.
- BoardCapabilities.HasHl2IoBoard gates the frontend control, HL2 only.

Off by default. With it off the EP2 stream is byte-identical to before.
With it on but no board fitted the cost is one read every 2 seconds, and
no writes ever happen because writes only start after detection.

Two details that matter on real hardware:

- The five TX-frequency bytes are latched per turnaround and written
  LSB-last, matching the firmware's commit-on-byte-0 behaviour, so a
  retune mid-burst cannot leave a torn frequency on the board.
- REG_RF_INPUTS is sent as 0 and is never inferred. Modes 1 and 2 switch
  the receiver onto the board's J9 input, and mode 2 also drives INTTR —
  the HL2's T/R relay — on receive. They are only correct when a separate
  receive antenna is wired to J9, and per N2ADR the register "is a user
  setting". An explicit operator control is the right way to expose it.

Wire encoding and cadence follow deskHPSDR's old_protocol.c case 11 and
the softerhardware/Hermes-Lite2 protocol documentation. Verified against a
real IO board on an HL2: detection, the frequency round-trip and the
0x3D reply path all confirmed on air.
The HL2+ companion board adds an AK4951 stream codec to a Hermes-Lite 2:
microphone input with PTT, headphone and speaker outputs, and CW sidetone,
on gateware that fills the same Protocol-1 audio slots every other codec
board uses.

An HL2+ answers discovery exactly like a stock HL2 — no board ID, no
gateware marker, nothing to probe — so the operator declares it, and that
declaration promotes the board's capability fingerprint:

    For(HermesLite2, variant, hl2PlusCodec: true)
        => caps with { HasOnboardCodec = true, HasMicBias = true }

Promoting the capability rather than special-casing each consumer is the
design. RadioSpeakerAudioSink, the Protocol-1 mic attach, ClampAudioSource
and the frontend already gate on HasOnboardCodec, so one lever lights all
of them, and almost no new audio plumbing was needed: ControlFrame already
writes the EP2 L/R slots and PacketParser already decodes the EP6 mic
slots. Every capability lookup that feeds a gate or a clamp must read the
promoted set — three separate faults during bring-up came from one that
did not, the worst being PushAudioFrontEnd, where ClampAudioSource sent
the operator's Radio Mic selection back to Host while every queryable
surface went on reporting RadioMic.

Config C3 bit 3 is the one hardware conflict. HL2+ gateware reads it as
"a codec is present"; mi0bot's HL2 fork reads the same bit as the Band
Volts PWM enable. The two are made mutually exclusive — declaring HL2+
force-disables Band Volts, arming Band Volts is refused while HL2+ is
declared, and hl2-options reports bandVoltsAvailable so the UI can
disable the control instead of watching a PUT be ignored.

CW sidetone is generated host-side and carried to the codec on the EP2
L/R slots while keyed, following deskHPSDR. The companion gateware does
not sound its own sidetone in practice, despite accepting internal_CW and
a non-zero sidetone level, so the host must supply it; the host's own
monitor tone is suppressed in that case so the operator hears one tone
from the radio rather than a second, late one from the PC. The keyer's
RF_delay now carries piHPSDR's value instead of zero — piHPSDR documents
it as working around a bug in the FPGA iambic keyer.

Also, board-agnostic and useful to any codec board: the radio-speaker
ring now primes before serving audio (its absence produced 5.3 seconds of
gaps on every start, heard as a run of pops) and corrects producer-vs-
packer rate drift a few samples at a time instead of in slices. Three
diagnostics endpoints report what the CW frames carry, the speaker ring's
latency and health, and every link in the radio-microphone chain — each
one located a fault that reading the code had not.

Off by default: a radio whose operator has not declared a companion board
keeps the stock HL2 fingerprint and byte-identical wire behaviour.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant