Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/HERMES.md
Original file line number Diff line number Diff line change
Expand Up @@ -1941,8 +1941,8 @@ receiver somewhere it cannot hear.

**The HL2 has no switchable filters of its own.** It has seven open-collector
outputs at `0x00[23:17]`, which the *gateware* forwards as one byte to I2C
address `0x20`. Nothing in this codebase writes I2C — setting the config bits
IS the whole mechanism (oracle §8).
address `0x20`. For this J16 path, setting the config bits is the whole mechanism
(oracle §8); the separate IO-board path below uses direct I2C2 writes.

Two things make this the riskiest change in the area:

Expand Down Expand Up @@ -1974,6 +1974,19 @@ readback anywhere in the protocol — the gateware writes to I2C and nothing
answers — so that log line is the only evidence of what the relays were told to
do, and a support log captured after the fact has to already contain it.

The external HL2 IO Board at I2C2 address `0x1D` is separate from J16.
It receives true transmit RF frequency as five single-byte writes to registers
0 through 4, MSB first; register 4 commits the value. Connect and band changes
push immediately, while same-band movement coalesces over 500 ms. An immediate
push supersedes any older pending frequency, and link loss clears the schedule.

MOX/TUNE do not defer the IO board alone: the existing TX NCO and filter paths
already follow a retune, so withholding only the amplifier leaves it on the
wrong band until unkey. This path does not provide cold relay sequencing or
acknowledged amplifier readiness. Ending transmission before changing bands
requires a separately approved change to the keying behavior. No IO-board
write asserts transmit intent; C0 MOX remains owned by the existing TX gate.

### 17.3 Verifying something with no readback

`tests/hl2_live_band_filter_probe.cpp` (hardware-only, `EXCLUDE_FROM_ALL`) is
Expand Down
118 changes: 118 additions & 0 deletions src/core/backends/hl2/Hl2Backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,13 @@ Hl2Backend::Hl2Backend(QObject* parent) : IRadioBackend(parent)
pushInitialState();
emitAllSliceState();
defineMeters();
// Tell the IO board where we came up. applyBandFilter() is NOT called on
// this path — the connect-time filter byte is primed straight into
// 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.
Comment thread
jensenpat marked this conversation as resolved.
applyIoBoardFrequency();
// At connect there is one receiver, so this is always "not wide" — but
// it is published rather than assumed, so the indicator starts from a
// stated value instead of whatever the widget happened to hold.
Expand All @@ -482,6 +489,7 @@ Hl2Backend::Hl2Backend(QObject* parent) : IRadioBackend(parent)
if (m_connected) {
m_connected = false;
m_linkStatsTimer->stop();
resetIoBoardSchedule();
emit disconnected();
}
});
Expand All @@ -497,6 +505,7 @@ Hl2Backend::Hl2Backend(QObject* parent) : IRadioBackend(parent)
QMetaObject::invokeMethod(m_metis, "stop", Qt::QueuedConnection);
m_connected = false;
m_linkStatsTimer->stop();
resetIoBoardSchedule();
emit connectionError(QStringLiteral("Hermes-Lite 2: %1").arg(reason));
});

Expand Down Expand Up @@ -4995,11 +5004,120 @@ double Hl2Backend::temperatureCelsius(int raw)
return (3.26 * (static_cast<double>(raw) / 4096.0) - 0.5) / 0.01;
}

void Hl2Backend::applyIoBoardFrequency()
{
if (!m_metis || m_rx.empty())
return;

// The TRANSMIT receiver's frequency — NOT the agree-or-bypass answer the
// filter board gets. The IO board switches amplifiers, antenna relays and
// transverters, all of which must follow where the operator will RADIATE.
// Receive slices parked on other bands are irrelevant to that, and the
// bypass result (kOcNone) is a relay pattern with no frequency to offer.
const Receiver* txRx = rx(m_txDdc);
const double hz = txRx ? txRx->sliceFreqHz : m_rx[0].sliceFreqHz;
if (!std::isfinite(hz) || hz <= 0.0 || hz > static_cast<double>(0xFF'FF'FF'FF'FFULL)) {
return; // validate before converting to the 40-bit field
}

// sliceFreqHz is TRUE-RF and the board's field wants true RF: it compares
// against band edges to pick a relay. The frequency-calibration scaling in
// ncoCommandHz() exists to correct the HL2's own reference and belongs only
// on values going to an NCO register — applying it here would hand the
// board a slightly wrong frequency for no reason.
const auto target = static_cast<quint64>(hz + 0.5);

// The band, from the same bandKeyForHz() table the per-band memory uses, so
// "which band is this" has exactly one answer in this backend.
const QString targetBand = bandKeyForHz(hz);
const bool bandChanged = (targetBand != m_ioBoardBandKey);

if (!m_ioBoardThrottle) {
m_ioBoardThrottle = new QTimer(this);
m_ioBoardThrottle->setSingleShot(true);
m_ioBoardThrottle->setInterval(kIoBoardThrottleMs);
connect(m_ioBoardThrottle, &QTimer::timeout, this, [this] {
const quint64 pending = m_ioBoardSchedule.takePending();
if (pending == 0) {
return; // cooldown expired with nothing waiting
}
if (!sendIoBoardFrequency(pending))
return; // disconnected: nothing to re-arm for
// Re-arm: a tune still in progress must keep coalescing.
m_ioBoardThrottle->start();
});
}

// Neither MOX nor TUNE defers the amplifier alone: the TX NCO/filter
// already follow the requested band. Immediate sends also discard an older
// coalesced value so the timeout cannot send the board back to that band.
switch (m_ioBoardSchedule.request(m_connected, m_ioBoardThrottle->isActive(),
bandChanged, target)) {
case IoBoardAction::DropDisconnected:
case IoBoardAction::Coalesce:
return;
case IoBoardAction::Send:
break;
}

Comment thread
jensenpat marked this conversation as resolved.
if (!sendIoBoardFrequency(target))
return;
m_ioBoardBandKey = targetBand;
// Restarted rather than left running, so the cooldown is measured from the
// push that actually went out — a band change mid-sweep resets the window
// instead of inheriting the remainder of the previous one.
m_ioBoardThrottle->start();
}

bool Hl2Backend::sendIoBoardFrequency(quint64 hz)
{
// THE ONE PLACE either edge of the throttle reaches the wire.
//
// It exists because the guard below was originally written into the
// trailing edge only, and the leading edge — the commoner path — silently
// lacked it. Two call sites that must agree about a hardware safety
// condition is one call site too many, so both now go through here and the
// asymmetry cannot come back.
//
// Do not let a disconnected tune enqueue work for a future session.
// The MetisClient guard and stop-time purge also enforce this at the wire.
if (!m_connected) {
m_ioBoardSchedule.reset();
return false;
}
QMetaObject::invokeMethod(m_metis, "setIoBoardTxFrequencyHz",
Qt::QueuedConnection, Q_ARG(quint64, hz));
return true;
}

void Hl2Backend::resetIoBoardSchedule()
{
// Called on linkDown. The timer's armed/pending state is about a session:
// left running across a disconnect, a reconnect inside the residual window
// takes the coalescing branch and stores the connect-time frequency as
// PENDING instead of pushing it — delaying the board by up to the cooldown
// at exactly the moment linkUp() intends an immediate push.
//
// The band key is cleared too, so the first push of the next session is
// always treated as a band change and takes the leading edge. Assuming the
// previous session's band still applies is precisely the assumption that
// cannot be made across a disconnect.
if (m_ioBoardThrottle)
m_ioBoardThrottle->stop();
m_ioBoardSchedule.reset();
m_ioBoardBandKey.clear();
}

void Hl2Backend::applyBandFilter(const char* reason)
{
if (!m_metis || m_rx.empty())
return;

// BEFORE the filter-byte comparison below, deliberately. The relay pattern
// is unchanged across a move from 7.100 to 7.200 MHz and this function
// returns early for it, but the IO board still needs the new frequency.
applyIoBoardFrequency();

// ONE filter board, N receivers.
//
// The J16 open-collector byte is a radio-wide register: there is a single
Expand Down
41 changes: 41 additions & 0 deletions src/core/backends/hl2/Hl2Backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <QTimer>

#include "core/backends/hl2/Hl2DbReference.h"
#include "core/backends/hl2/Hl2IoBoardPolicy.h"
#include "core/backends/hl2/Hl2Receivers.h"
#include "core/backends/hl2/MetisProtocol.h" // Hl2Telemetry

Expand Down Expand Up @@ -150,6 +151,22 @@ class Hl2Backend : public IRadioBackend {
// frequency. Idempotent and change-gated, so it is safe to call from every
// path that can move the dial.
void applyBandFilter(const char* reason);
// Push the transmit frequency to the HL2 IO Board, throttled.
//
// Called from applyBandFilter() so it inherits every trigger that can move
// a band — tune, TX slice, key/unkey, pan, add/close receiver — but from
// ABOVE that function's `oc == m_ocFilterByte` early return, because the
// two have different resolutions. The filter byte is one of seven relays
// and does not change between 7.100 and 7.200 MHz; the IO board wants the
// frequency itself and does.
void applyIoBoardFrequency();
// The single point at which either edge of the IO-board throttle reaches
// the wire, so the disconnected guard cannot be present on one path and
// missing on the other. Returns false when the push was refused.
[[nodiscard]] bool sendIoBoardFrequency(quint64 hz);
// Drop the IO-board schedule on linkDown: armed timer, coalesced value and
// remembered band all describe a session and must not survive one.
void resetIoBoardSchedule();
// Per-band memory (RFC #4603 PR 3): apply the remembered LNA + drive for
// the band containing freqHz (falling back to the restored defaults),
// and record the operator's current values into the maps for the band
Expand Down Expand Up @@ -595,6 +612,30 @@ class Hl2Backend : public IRadioBackend {
QTimer* m_bandwidthThrottle = nullptr;
double m_pendingBandwidthHz = 0.0; // 0 = nothing coalesced

// The IO board's README asks for at most one frequency update every 0.5 s,
// and only on change. Leading edge queues immediately; wire delivery and relay settling
// are not acknowledged, so this is not an amplifier-ready interlock;
// anything arriving inside the cooldown is coalesced and the LAST value
// applied when it expires.
//
// Coalesce-and-apply, never drop: a VFO wheel delivers ~10 tune events a
// second, and simply discarding those inside the window would leave the
// amplifier on the old band whenever the operator stopped turning mid-
// cooldown — the one moment they are most likely to key.
static constexpr int kIoBoardThrottleMs = 500;
QTimer* m_ioBoardThrottle = nullptr;
hl2::IoBoardSchedule m_ioBoardSchedule;
// The band the IO board was last told about, as a bandKeyForHz() key.
// Empty means "no session has told it anything", which is also the state
// reset() restores — so the first push after any connect is treated as a
// band change and takes the leading edge rather than being coalesced.
//
// Tracked SEPARATELY from m_currentBandKey (the per-band memory's notion):
// that one follows the operator's tuning for LNA/drive recall and moves on
// paths this does not, and conflating "what the operator is on" with "what
// the amplifier has been told" is how the two silently diverge.
QString m_ioBoardBandKey;

// Has this connect already derived the passband from the mode? (#4484)
//
// pushInitialState() runs on every linkUp, and MetisClient re-emits linkUp
Expand Down
49 changes: 49 additions & 0 deletions src/core/backends/hl2/Hl2IoBoardPolicy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#pragma once

#include <cstdint>
#include <utility>

namespace AetherSDR::hl2 {

enum class IoBoardAction { Send, Coalesce, DropDisconnected };

// The radio already moves its TX NCO and filter on a band change, including
// during MOX/TUNE. Deferring only the amplifier until unkey strands it on the
// old band. This schedule follows the radio; it does not sequence RF/relays.
[[nodiscard]] constexpr IoBoardAction ioBoardAction(bool connected,
bool throttleActive,
bool bandChanged) noexcept
{
if (!connected) {
return IoBoardAction::DropDisconnected;
}
if (throttleActive && !bandChanged) {
return IoBoardAction::Coalesce;
}
return IoBoardAction::Send;
}

// Pending work belongs to the current scheduling window. An immediate band
// change supersedes a same-band value waiting in the previous window.
class IoBoardSchedule {
public:
[[nodiscard]] IoBoardAction request(bool connected, bool throttleActive,
bool bandChanged, std::uint64_t hz) noexcept
{
const IoBoardAction action = ioBoardAction(connected, throttleActive, bandChanged);
m_pendingHz = action == IoBoardAction::Coalesce ? hz : 0;
return action;
}

[[nodiscard]] std::uint64_t takePending() noexcept
{
return std::exchange(m_pendingHz, 0);
}

void reset() noexcept { m_pendingHz = 0; }

private:
std::uint64_t m_pendingHz = 0;
};

} // namespace AetherSDR::hl2
40 changes: 40 additions & 0 deletions src/core/backends/hl2/MetisClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,14 @@ bool MetisClient::start(const Params& params)
m_haveRxSeq = false;
m_drops = 0;
m_linkUp = false;
// This object OUTLIVES a connect: Hl2Backend builds it in its constructor
// and deletes it in its destructor, so without this the dedupe would carry
// a frequency across a disconnect and suppress the first push of the next
// session. The IO board may have been power-cycled in between, and nothing
// in the protocol can be asked what it currently holds -- the same reason
// the band filter is re-primed at every connect rather than trusted.
m_ioBoardTxFreqSent = false;
m_ioBoardTxFreqHz = 0;

m_socket = new QUdpSocket(this);
if (!m_socket->bind(QHostAddress::AnyIPv4, 0)) {
Expand Down Expand Up @@ -351,6 +359,13 @@ void MetisClient::stop()
m_socket = nullptr;
}
m_running = false;
// An interrupted five-bank write must not finish in the next session.
// Preserve unrelated one-shot setup; only this board's writes are stale.
std::erase_if(m_oneShot, [](const Cc& bank) {
return bank[0] == kC0I2c2 && bank[1] == kI2cCookieWrite
&& bank[2] == (kI2cStopAtEnd | kIoBoardI2cAddr);
});
m_ioBoardTxFreqSent = false;
if (m_linkUp) {
m_linkUp = false;
emit linkDown();
Expand Down Expand Up @@ -530,6 +545,31 @@ void MetisClient::setBandFilter(int ocFilterByte)
m_oneShot.push_back(m_ccConfig);
}

void MetisClient::setIoBoardTxFrequencyHz(quint64 hz)
{
// Refuse disconnected requests even if a caller missed the backend guard.
// stop() also discards any unfinished board write from a running session.
if (!m_running)
return;
if (m_ioBoardTxFreqSent && hz == m_ioBoardTxFreqHz)
return; // already queued in this session
m_ioBoardTxFreqSent = true;
m_ioBoardTxFreqHz = hz;
// INFO, not debug, and for the same reason the band filter is: there is no
// readback. The board never answers -- we deliberately do not set RQST --
// so this line is the only record of what an amplifier was told to switch
// to, and a support log captured after a mis-keying has to already have it.
qCInfo(lcHl2).nospace()
<< "HL2 IO board: TX frequency -> "
<< QString::number(static_cast<double>(hz) / 1.0e6, 'f', 6)
<< " MHz (I2C2 chip 0x1D, 5 banks)";
// Order is the batch's, not ours -- see ccIoBoardTxFrequency(). Appending
// in sequence is the whole contract: the deque preserves it, and the last
// bank is the one that makes the board latch.
for (const Cc& bank : ccIoBoardTxFrequency(hz))
m_oneShot.push_back(bank);
}

void MetisClient::requestPipelineReset()
{
// DELIBERATELY A NO-OP. Do not re-enable without reading this.
Expand Down
28 changes: 28 additions & 0 deletions src/core/backends/hl2/MetisClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,26 @@ class MetisClient : public QObject {
// recorded from this declaration, and `std::uint8_t` does not normalize to
// the same string as `unsigned char`.
Q_INVOKABLE void setBandFilter(int ocFilterByte);
// Push the TRANSMIT frequency to the HL2 IO Board (I2C2 chip 0x1D) so an
// attached amplifier, antenna relay or transverter follows the band.
//
// Five one-shot banks, LSB last, because that register is what commits the
// value on the board — see ccIoBoardTxFrequency(), which owns the ordering.
// They ride m_oneShot rather than the rotation for the same reason the
// filter byte does: an amplifier still switched to the previous band when
// the operator keys is the failure that matters, and the deque both
// preserves order and drains one bank per EP2 frame (~2.6 ms at 48 kHz),
// so all five land in about 13 ms.
//
// SENT UNCONDITIONALLY, with no "do you have an IO board" setting, on the
// same reasoning the J16 filter byte is driven blind: a chip that is not on
// the bus NACKs its address, the gateware's i2c_master raises missed_ack
// and moves on. Costing an absent board nothing is what makes the setting
// unnecessary, and a setting defaulted off is a support burden — the
// symptom of forgetting it is an amplifier on the wrong band.
//
// quint64, not std::uint32_t: the board's field is 40 bits wide.
Q_INVOKABLE void setIoBoardTxFrequencyHz(quint64 hz);
[[nodiscard]] std::uint8_t bandFilter() const noexcept { return m_params.ocFilterByte; }
// Queue a one-shot filter-pipeline reset (MetisProtocol kC0Sync) to be sent
// on the next EP2 frame, ahead of the round robin.
Expand Down Expand Up @@ -373,7 +393,15 @@ private slots:
// Ordering matters: a frequency change and its pipeline reset must reach the
// radio in that order, and neither should wait up to three frames for the
// rotation to come back around.
friend struct MetisClientTestAccess; // socket-free transport-state injection
std::deque<Cc> m_oneShot; // which register pair to send next
// Last transmit frequency handed to the IO board, and whether one ever was.
// A separate flag rather than a 0 sentinel: 0 Hz is not a plausible tuned
// frequency, but "never sent" still has to survive a radio that legitimately
// reports it, and the first push after connect must go out even if the
// backend's throttle happens to compute the same value it had before.
quint64 m_ioBoardTxFreqHz = 0;
bool m_ioBoardTxFreqSent = false;
std::uint32_t m_expectedRxSeq = 0; // for EP6 drop detection
bool m_haveRxSeq = false;
quint64 m_drops = 0;
Expand Down
Loading
Loading