Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
127 changes: 127 additions & 0 deletions src/core/backends/hl2/Hl2Backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,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 @@ -474,6 +481,7 @@ Hl2Backend::Hl2Backend(QObject* parent) : IRadioBackend(parent)
if (m_connected) {
m_connected = false;
m_linkStatsTimer->stop();
resetIoBoardSchedule();
emit disconnected();
}
});
Expand All @@ -489,6 +497,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 @@ -4954,11 +4963,129 @@ 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 (!(hz > 0.0))
return; // also rejects NaN, which a comparison to <= 0 would not

// 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] {
if (m_pendingIoBoardHz == 0)
return; // cooldown expired with nothing waiting
const quint64 pending = m_pendingIoBoardHz;
m_pendingIoBoardHz = 0;
if (!sendIoBoardFrequency(pending))
return; // disconnected: nothing to re-arm for
// Re-arm: a tune still in progress must keep coalescing.
m_ioBoardThrottle->start();
});
}

// The whole decision, in one place, from a policy the suite can exercise
// without a radio — see Hl2IoBoardPolicy.h for why each condition sits
// where it does. m_tuning counts as keyed: TUNE radiates.
switch (ioBoardAction(m_connected, m_keyed || m_tuning,
m_ioBoardThrottle->isActive(), bandChanged)) {
case IoBoardAction::DropDisconnected:
m_pendingIoBoardHz = 0;
return;
case IoBoardAction::DeferKeyed:
// Deliberately not stashed: unkey re-runs this path and recomputes.
return;
case IoBoardAction::Coalesce:
m_pendingIoBoardHz = target; // superseded by any later request
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.
//
// WHY DISCONNECTED MATTERS: MetisClient::m_oneShot is not cleared by
// start() or stop(), and unlike the filter byte the IO-board frequency is
// not re-primed through Params. A bank queued while down therefore survives
// as the FIRST thing the next session transmits, briefly pointing an
// amplifier at the band this session ended on. linkUp() pushes the real
// frequency immediately afterwards, so dropping it here loses nothing.
if (!m_connected) {
m_pendingIoBoardHz = 0;
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_pendingIoBoardHz = 0;
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 @@ -143,6 +144,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 @@ -588,6 +605,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 applies IMMEDIATELY, so an operator who
// changes band and keys straight away finds the amplifier already switched;
// 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;
quint64 m_pendingIoBoardHz = 0; // 0 = nothing coalesced
// 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
67 changes: 67 additions & 0 deletions src/core/backends/hl2/Hl2IoBoardPolicy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#pragma once

// When an IO-board frequency push may go out, as a pure decision.
//
// WHY THIS IS A POLICY AND NOT AN `if` CHAIN IN THE BACKEND. The encoder that
// builds the five I2C banks is straightforward and was right first time; every
// defect in this feature has been in the SCHEDULING around it — a guard present
// on the trailing edge of a throttle and missing on the leading one, an armed
// timer surviving a disconnect, a band change coalesced as though it were
// ordinary frequency drift. Those are conditions, not arithmetic, and they are
// only testable if they live somewhere a test can reach without a radio.
//
// The thing being scheduled points an amplifier's band relay. That is the
// reason the ordering below is fixed and commented rather than left to whoever
// next edits the call site.

namespace AetherSDR::hl2 {

enum class IoBoardAction {
// Push now. The caller sends, records the band, and restarts the cooldown.
Send,
// Same-band movement inside the cooldown: remember it as pending and let
// the timer deliver the latest value when the window expires.
Coalesce,
// Transmitting. Do nothing at all, and do NOT stash the value: unkey runs
// the whole path again and recomputes from the TX receiver, which is
// fresher than anything held here.
DeferKeyed,
// Not connected. Do nothing AND discard any pending value.
DropDisconnected,
};

// The decision, in the order the conditions must be tested.
//
// DISCONNECTED FIRST, because the consequence outlives the session:
// MetisClient::m_oneShot is cleared by neither start() nor stop(), and the
// IO-board frequency — unlike the filter byte — is not re-primed through
// Params. A bank queued while the link is down therefore becomes the FIRST
// thing the next connect transmits, pointing an amplifier at the band the
// previous session ended on.
//
// KEYED SECOND, because switching a band relay under RF burns its contacts.
// The operator normally cannot retune mid-transmission on this radio, but
// connect-time pushes and the automation bridge can both reach the scheduler
// while MOX is up, and the cost of being wrong is someone's hardware.
//
// BAND CHANGE BEATS THE THROTTLE, because the rate limit exists for VFO sweeps
// (~10 events/second against a board that asks for two) and a band crossing is
// not that case: applyBandFilter moves the physical filter relay immediately,
// so deferring the amplifier leaves the two disagreeing for up to the cooldown
// — and keying in that window is exactly the hazard. Rate-limit frequency
// tracking; never rate-limit which band the amplifier is on.
[[nodiscard]] constexpr IoBoardAction ioBoardAction(bool connected,
bool keyed,
bool throttleActive,
bool bandChanged) noexcept
{
if (!connected)
return IoBoardAction::DropDisconnected;
if (keyed)
return IoBoardAction::DeferKeyed;
if (throttleActive && !bandChanged)
return IoBoardAction::Coalesce;
return IoBoardAction::Send;
}

} // namespace AetherSDR::hl2
37 changes: 37 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 @@ -530,6 +538,35 @@ void MetisClient::setBandFilter(int ocFilterByte)
m_oneShot.push_back(m_ccConfig);
}

void MetisClient::setIoBoardTxFrequencyHz(quint64 hz)
{
// DEFENCE IN DEPTH against a bank outliving its session. m_oneShot is not
// cleared by start() or stop(), so anything queued while the stream is down
// becomes the first thing the NEXT connect transmits -- pointing an
// amplifier at the band the previous session ended on. Hl2Backend already
// refuses to schedule while disconnected; this is the wire's own refusal,
// so a future caller that misses that guard cannot reintroduce the hazard.
if (!m_running)
return;
if (m_ioBoardTxFreqSent && hz == m_ioBoardTxFreqHz)
return; // board already holds this frequency
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
27 changes: 27 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 @@ -374,6 +394,13 @@ private slots:
// radio in that order, and neither should wait up to three frames for the
// rotation to come back around.
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