Skip to content
Open
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
22 changes: 14 additions & 8 deletions docs/architecture/aetherd-icom-civ-backend-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -680,14 +680,20 @@ captures from our own radio.
## 9. Explicitly out of scope for phase 1

- **IQ.** It does not exist on this radio. Not deferred — absent.
- **Writable memory channels.** Initial IC-705, IC-7300MK2, and IC-9700 support
reads their model-specific ordinary-channel records with `1A 00`, exposes occupied
channels through the shared memory model, and permits tuning to the cached
channel state. Reads are button-only; IC-705 requires a selected group so a
click queues 100 requests rather than scanning its 10,000-address space.
Writing, adding, deleting, scan-edge, call, and satellite
memories remain deferred. Other Icom models continue to use the client-side
bank until their own published record layouts are implemented and verified.
- **Writing radio memory channels.** All Icom radios use AetherSDR's shared,
writable memory database as the working model. For IC-705, IC-7300MK2, and
IC-9700, **Sync Memories** reads the model-specific ordinary-channel records
with `1A 00` and ingests occupied channels into that database; Tune then
recalls the durable database row like a manual or CSV-imported memory.
Imported rows are keyed by the 16-byte radio GUID from the authenticated
RS-BA1 capabilities record plus the native group/channel, so DHCP, mDNS and
NAT endpoint changes cannot duplicate a radio's channel set. Reads are
button-only; IC-705 requires a selected native group so a click queues 100
requests rather than scanning its 10,000-address space. Flex global/TX
profiles are not valid Icom group selectors. Writing or deleting the radio's
own channels, plus scan-edge, call, and satellite memories, remain deferred.
Other Icom models still use the same client-side database, but expose no Sync
action until their published record layout is implemented and verified.
- **D-STAR / DV.** A large command surface (`22 xx`, `23 xx`) and a separate
feature.
- **Bluetooth transport.** Unknown whether it carries all three streams.
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/radio-capabilities-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ traps and why the DAX crash guard is deliberately *not* the DAX capability.
| `hasPaCurrentTelemetry` | ❌ | ❌ | ❌ | `MainWindow::applyCapabilitiesToUi` | Calibrated PA drain-current face in Radio Vitals, used only when PA-temperature telemetry is unavailable. Icom: ✅ only for the IC-9700 profile's documented 0–20 A Id calibration. Flex remains ❌ because its PACURRENT meter is known to clip below real full-power draw |
| `hasMainFanTelemetry` | ✅ | ❌ | ❌ | `MainWindow::applyCapabilitiesToUi` | Main Fan gauge in Radio Vitals. All current Icom models are ❌ because the backend does not publish fan-speed telemetry |
| `hostFrequencyCalibration` | ❌ | ✅ | ❌ | `RadioSetupDialog` (Calibration page), `AutomationServer::doFreqCal` | Shows the Calibration page and enables the `freqcal` bridge verb. Means "**the client** owns the frequency-error correction", not "this radio has an error" — every radio does. Flex is ❌ because it calibrates itself (`radio set cal_freq` / `pll_start`), and that surface stays in the Frequency Offset group on the Receive page. HL2 is ✅ because its 76.8 MHz NCO scale is a `localparam` in the bitstream (`radio.v` M2) and no register in the HPSDR map accepts a correction — see `docs/architecture/hl2-frequency-calibration.md` |
| `persistsMemories` | ✅ | ❌ | ❌ | `LocalMemoryBank` engagement (#4590) | host-side memory bank vs radio-side slots the bank's ONE shared document lives at `radio_settings (local, '', MemoryBank)` since RFC #4603 PR 6, covered by settings backup/export; legacy `memories.json` is a frozen import source. Icom is profile-gated for IC-705, IC-7300MK2, and IC-9700; unprofiled Icom models remain ❌. |
| `canWriteMemories` | ✅ | ❌ | ❌ | `RadioModel::memoriesWritable`, memory dialog and panadapter memory panel | Separates radio ownership from mutation support. The initial Icom implementation is read-only, so Add, Import, inline edits, and Remove cannot leak vendor-incompatible memory commands. |
| `persistsMemories` | ✅ | ❌ | ❌ | `LocalMemoryBank` engagement (#4590) | selects the active working store: native radio slots or the host database. The bank's ONE shared document lives at `radio_settings (local, '', MemoryBank)` since RFC #4603 PR 6, covered by settings backup/export; legacy `memories.json` is a frozen import source. Icom is always ❌ because its working model is the host database; model-specific Sync support is declared independently by `canRefreshMemories`. |
| `canWriteMemories` | ✅ | ❌ | ❌ | `RadioModel::memoriesWritable`, memory dialog and panadapter memory panel | Separates native ownership from mutation support. Icom's radio-side store stays read-only, while the shared AetherSDR database remains writable for Add, Import, inline edits, Remove, and Tune on every Icom model. |
| `canApplyMemories` | ✅ | ❌ | ❌ | `RadioModel::tryMemoryCommand` | True means the backend accepts its native memory-apply command. Initial Icom support is ❌ and applies recallable cached fields through the existing neutral slice setters instead of entering vendor Memory mode; split/RPS/DV/DD records are display-only. |
| `canRefreshMemories` | ❌ | ❌ | ❌ | Memory Channels dialog → `RadioModel::refreshMemories` | Explicit, button-only radio-memory snapshots. IC-7300MK2 reads 99 channels; IC-9700 reads all 297 or one selected band; IC-705 requires one selected group and reads only its 100 channels. No memory scan runs during connection. |
| `clientSettingsDomains` | empty | Tuning\|Passband\|SpanRate\|RfGain\|TxSetpoints\|Memories\|Agc | empty | `RadioStateMemory::shouldEngage` → `RadioModel::handRestoredStateToBackend` | connect-time operating-state restore + debounced capture (RFC #4603 PR 3): `Hl2Backend::applyRestoredState` seeds rate/freq/LNA at connect, `pushInitialState` applies restored mode+passband (reconciled with #4484 — restored as a pair, so mode and passband cannot disagree) and the start band's drive; per-band LNA/drive maps ride the extension document and follow TX-slice band changes. `Agc` (#4909) carries the mode + threshold pair as typed universal fields — FLAT, not per-band, and seeded onto EVERY receiver by `Hl2Backend::seedReceiverAgc()`, because the AGC runs in host-side WDSP and no HPSDR register can be asked what it is. Seeding runs from `connectRadio` when the connect SERIAL changes or the receivers were rebuilt from nothing — never on a plain auto-reconnect, because `handRestoredStateToBackend` re-hands the document before every connect and `buildReceivers` preserves live receiver state, so an unconditional seed flattened per-receiver AGC on each dropped link. Memories is declarative only — the bank engages on `persistsMemories` and keeps its own shared document (PR 6). Flex/Sim: no-op by empty declaration. |
Expand Down
37 changes: 37 additions & 0 deletions src/core/LocalMemoryBank.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include "core/AppSettings.h"
#include "core/LocalMemoryStore.h"
#include "core/LogManager.h"
#include "core/MemoryFieldValues.h"
#include "core/backends/MemoryWireCodec.h"

#include <QDateTime>
Expand Down Expand Up @@ -120,6 +121,29 @@ void LocalMemoryBank::load()
return;
}

// The first Icom memory-import build persisted every IC-7300MK2 channel as
// display-only after mistaking its fixed-length RX+TX record for proof that
// Split was enabled. Recallability is a property of the neutral database
// row: if its mode can be applied to an AetherSDR slice, stale Icom import
// metadata must not permanently prevent navigation. Repair the rows once
// on load and persist the corrected document atomically.
int repairedRecallability = 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker 3 — three verified defects in this loop. (a) It keys on MemoryFields::isKnownMode, but the codec emits "CWL" (Cw-R) and "WFM" — neither is in modes() — so rows poisoned by the first build in those modes stay display-only while their neighbours heal. (b) It sits below if (!parsed.ok()) return;, so one non-fatal bad row (a duplicate slot in a hand-edited export) disables the entire repair. (c) It is un-versioned and runs on every launch: the moment DV/DD gain a neutral mapping (DSTR/FDV are already in modes()), every deliberately display-only row flips recallable forever, and no codec decision can stick. It also makes load() a writer — which can trip the foreign-write guard in the two-window case — and logs "repaired N" even when the flush refused.

The kFormatVersion 1→2 bump this PR makes is the one-shot hook the comment's "once" wants: gate on storedRowVersion < 2, key the predicate on the codec's own rule, and do it in the store's schema layer.

for (auto it = m_entries.begin(); it != m_entries.end(); ++it) {
if (!it->recallable
&& it->importSource.startsWith(QLatin1String("icom:"))
&& MemoryFields::isKnownMode(it->mode)) {
it->recallable = true;
++repairedRecallability;
}
}
if (repairedRecallability > 0) {
m_dirty = true;
flush();
qCInfo(lcProtocol).noquote()
<< "LocalMemoryBank: repaired recallability for"
<< repairedRecallability << "Icom-imported memories";
}

if (importedFromLegacy) {
// Claim the legacy channels into the document now, so the migration
// is not contingent on the operator making an edit first. The legacy
Expand Down Expand Up @@ -148,6 +172,19 @@ int LocalMemoryBank::allocateSlot() const
return index;
}

int LocalMemoryBank::importedSlot(const QString& source, const QString& key) const
{
if (source.isEmpty() || key.isEmpty()) {
return -1;
}
for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) {
if (it->importSource == source && it->importKey == key) {
return it.key();
}
}
return -1;
}

LocalMemoryBank::CommandResult LocalMemoryBank::handleCommand(const QString& command)
{
CommandResult result;
Expand Down
4 changes: 4 additions & 0 deletions src/core/LocalMemoryBank.h
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class LocalMemoryBank : public QObject {
bool isWritable() const { return m_writable; }

const QMap<int, MemoryEntry>& entries() const { return m_entries; }
// Locate a row previously ingested from the same external source. The pair
// is deliberately independent of the client slot number: native radio
// channel numbers and CSV row numbers may collide with manual memories.
int importedSlot(const QString& source, const QString& key) const;

// Handle one `memory …` command. Returns handled=false for anything outside
// the four verbs above.
Expand Down
20 changes: 20 additions & 0 deletions src/core/LocalMemoryStore.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,24 @@ QJsonObject entryToJson(const MemoryEntry& m)
o["index"] = m.index;
o["group"] = m.group;
o["owner"] = m.owner;
o["channel"] = m.channel;
o["importSource"] = m.importSource;
o["importKey"] = m.importKey;
o["freq"] = m.freq;
o["name"] = m.name;
o["mode"] = m.mode;
o["nativeFilter"] = m.nativeFilter;
o["dataMode"] = m.dataMode;
o["step"] = m.step;
o["offsetDir"] = m.offsetDir;
o["repeaterOffset"] = m.repeaterOffset;
o["toneMode"] = m.toneMode;
o["toneValue"] = m.toneValue;
o["rxToneValue"] = m.rxToneValue;
o["dtcsCode"] = m.dtcsCode;
o["dtcsTxReverse"] = m.dtcsTxReverse;
o["dtcsRxReverse"] = m.dtcsRxReverse;
o["recallable"] = m.recallable;
o["squelch"] = m.squelch;
o["squelchLevel"] = m.squelchLevel;
o["rxFilterLow"] = m.rxFilterLow;
Expand All @@ -48,14 +58,24 @@ MemoryEntry entryFromJson(const QJsonObject& o)
m.index = o.value("index").toInt(m.index);
m.group = o.value("group").toString(m.group);
m.owner = o.value("owner").toString(m.owner);
m.channel = o.value("channel").toString(m.channel);
m.importSource = o.value("importSource").toString(m.importSource);
m.importKey = o.value("importKey").toString(m.importKey);
m.freq = o.value("freq").toDouble(m.freq);
m.name = o.value("name").toString(m.name);
m.mode = o.value("mode").toString(m.mode);
m.nativeFilter = o.value("nativeFilter").toInt(m.nativeFilter);
m.dataMode = o.value("dataMode").toInt(m.dataMode);
m.step = o.value("step").toInt(m.step);
m.offsetDir = o.value("offsetDir").toString(m.offsetDir);
m.repeaterOffset = o.value("repeaterOffset").toDouble(m.repeaterOffset);
m.toneMode = o.value("toneMode").toString(m.toneMode);
m.toneValue = o.value("toneValue").toDouble(m.toneValue);
m.rxToneValue = o.value("rxToneValue").toDouble(m.rxToneValue);
m.dtcsCode = o.value("dtcsCode").toInt(m.dtcsCode);
m.dtcsTxReverse = o.value("dtcsTxReverse").toBool(m.dtcsTxReverse);
m.dtcsRxReverse = o.value("dtcsRxReverse").toBool(m.dtcsRxReverse);
m.recallable = o.value("recallable").toBool(m.recallable);
m.squelch = o.value("squelch").toBool(m.squelch);
m.squelchLevel = o.value("squelchLevel").toInt(m.squelchLevel);
m.rxFilterLow = o.value("rxFilterLow").toInt(m.rxFilterLow);
Expand Down
12 changes: 6 additions & 6 deletions src/core/LocalMemoryStore.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@

namespace AetherSDR {

// Portable, versioned JSON persistence for the CLIENT-side memory bank — the
// channels an operator saves on a radio that has no memory storage of its own
// (Hermes-Lite 2, Kiwi, the demo backend). On a Flex the radio owns the slots
// and this file is never touched; see RadioCapabilities::persistsMemories.
// Portable, versioned JSON persistence for the CLIENT-side memory bank — both
// channels the operator creates here and snapshots explicitly imported from a
// radio. On a Flex the radio owns and mutates the active slots, so this document
// is not the session store; see RadioCapabilities::persistsMemories.
//
// Envelope:
// {
// "format": "aether.memories",
// "version": 1,
// "version": 2,
// "savedAt": "2026-07-29T14:00:00Z",
// "savedBy": "AetherSDR",
// "memories": [ { "index": 0, ...MemoryEntry... } ]
Expand All @@ -35,7 +35,7 @@ namespace AetherSDR {
// bank is sparse.
class LocalMemoryStore {
public:
static constexpr int kFormatVersion = 1;
static constexpr int kFormatVersion = 2;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker 4 — a one-way door for purely additive fields. Every v2 field is read value(...).toX(default), so a v1 build parses a v2 document harmlessly — yet flush() stamps 2 on any save, load() refuses to write when stored > built, and the load-time repair triggers a flush without any user action. Launch this build once with synced rows, roll back, and the previous release shows an empty, read-only memory bank — indistinguishable from data loss, in the PR fixing an indistinguishable-from-data-loss bug. Keep additive fields at version 1 (or bump only on an actual incompatibility, and never auto-upgrade from load()).

static constexpr const char* kFormatId = "aether.memories";

// The bank's home since RFC #4603 PR 6: ONE shared feature document in
Expand Down
2 changes: 2 additions & 0 deletions src/core/backends/MemoryDelta.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ struct MemoryDelta {
std::optional<QString> group;
std::optional<QString> owner;
std::optional<QString> channel;
std::optional<QString> importSource;
std::optional<QString> importKey;
std::optional<QString> name;
std::optional<QString> mode;
std::optional<QString> offsetDir; // wire key "repeater"
Expand Down
10 changes: 5 additions & 5 deletions src/core/backends/RadioCapabilities.h
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,11 @@ struct RadioCapabilities {
// this true when it can prove the radio gives the slots back.
bool persistsMemories = false;

// Whether the radio-backed memory store accepts mutations and native
// recalls. These are deliberately separate from persistsMemories: an
// initial backend may prove that it can enumerate radio-owned channels
// before it is safe to overwrite them, and may expose those channels as
// tune presets without putting the radio into its vendor Memory mode.
// Whether the active memory store accepts mutations/native recalls, and
// whether the radio can be read as an explicit import source. Refresh is
// deliberately independent of persistsMemories: Icom keeps AetherSDR's
// shared client database as the working store while model-specific codecs
// ingest snapshots from the radio into it.
bool canWriteMemories = false;
bool canApplyMemories = false;
bool canRefreshMemories = false;
Expand Down
40 changes: 38 additions & 2 deletions src/core/backends/icom/IcomCivBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -461,10 +461,14 @@ RadioCapabilities IcomCivBackend::capabilities() const
c.hasRadioSideWaterfallAutoBlack = false;
const MemoryProfile* memory = m_model && profileFor(*m_model).memory
? &*profileFor(*m_model).memory : nullptr;
c.persistsMemories = memory != nullptr;
// The AetherSDR memory model is always the shared client database for
// Icom. A model-specific codec only adds an explicit radio-to-database
// Sync source; it does not hand ownership of the working store to the
// radio.
c.persistsMemories = false;
c.canWriteMemories = false;
c.canApplyMemories = false;
c.canRefreshMemories = c.persistsMemories;
c.canRefreshMemories = memory != nullptr;
if (memory) {
c.memoryGroupColumnTitle = QString::fromLatin1(memory->groupColumnTitle.data(),
static_cast<qsizetype>(memory->groupColumnTitle.size()));
Expand Down Expand Up @@ -726,6 +730,11 @@ void IcomCivBackend::connectRadio(const RadioConnectRequest& request)
{
disconnectRadio();

// The stable import identity arrives in the authenticated RS-BA1
// capabilities record. An endpoint is deliberately not used here: DHCP,
// mDNS and NAT changes must not turn one radio into a second import source.
m_memoryImportSource.clear();

IcomSession::Params p;
p.host = QHostAddress(request.host);
p.controlPort = request.port ? request.port : kControlPort;
Expand Down Expand Up @@ -1110,6 +1119,14 @@ void IcomCivBackend::refreshMemories(const QString& groupName)
return;
}
const MemoryProfile& memory = *profileFor(*m_model).memory;
if (m_memoryImportSource.isEmpty()) {
qCWarning(lcIcomCiv)
<< "memory sync refused: RS-BA1 supplied no stable radio identity";
emit configurationWarning(
QStringLiteral("This radio did not provide a stable RS-BA1 identity, so its "
"memories cannot be synced safely."));
return;
}
int selectedGroup = -1;
if (!groupName.isEmpty() && memory.firstGroup >= 0) {
for (int group = memory.firstGroup; group <= memory.lastGroup; ++group) {
Expand All @@ -1120,6 +1137,10 @@ void IcomCivBackend::refreshMemories(const QString& groupName)
}
}
if (memory.requiresGroupSelection && selectedGroup < memory.firstGroup) {
qCWarning(lcIcomCiv)
<< "memory sync refused: invalid group selection" << groupName;
emit configurationWarning(
QStringLiteral("Choose a valid Icom memory group before syncing."));
return;
}
m_memoryRefreshActive = true;
Expand Down Expand Up @@ -1379,6 +1400,13 @@ void IcomCivBackend::adoptReportedCivAddress(std::uint8_t reported)
void IcomCivBackend::onSessionConnected(const QString& deviceName)
{
m_deviceName = deviceName.trimmed();
const std::string stableRadioId = radioIdHex(m_session->radioId());
if (stableRadioId.empty()) {
m_memoryImportSource.clear();
} else {
m_memoryImportSource = QStringLiteral("icom:%1").arg(
QString::fromStdString(stableRadioId));
}
m_connected = true;
m_connectedAtMs = nowMs();
m_lastIncident.clear();
Expand Down Expand Up @@ -2546,6 +2574,14 @@ void IcomCivBackend::onCivFrame(const CivFrame& frame,
}
MemoryDelta delta;
delta.index = index;
delta.importSource = m_memoryImportSource;
delta.importKey = QStringLiteral("%1:%2")
.arg(memory->group)
.arg(memory->channel);
delta.owner = m_model

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker 2 — this re-stamp lands on rows the operator has since edited. importedSlot now matches existing rows, owner/group/name are stamped on every pass, and applyMemoryChanges applies any present field — so an annotation ("W6ABC trustee — verify PL"), a corrected name, or a re-grouping is silently reverted and persisted on the next Sync. The body's "does not overwrite manual/CSV memories" guarantee doesn't cover the imported rows' operator edits, which are exactly the rows Sync revisits.

Options: stamp identity-ish fields only on first insert; carry a per-row operator-touched mask; or diff-and-warn. Any of the three preserves the idempotent-upsert property the live test proved.

? QString::fromLatin1(m_model->name.data(),
static_cast<qsizetype>(m_model->name.size()))
: QStringLiteral("Icom");
if (!memory->occupied) {
delta.removed = true;
emit memoryChanged(delta);
Expand Down
1 change: 1 addition & 0 deletions src/core/backends/icom/IcomCivBackend.h
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,7 @@ private slots:
QTimer* m_tuneTimer = nullptr;

QString m_deviceName;
QString m_memoryImportSource;
std::uint64_t m_frequencyHz = 0;
CivMode m_mode = CivMode::Usb;
bool m_dataMode = false;
Expand Down
15 changes: 13 additions & 2 deletions src/core/backends/icom/IcomMemoryCodec.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,13 @@ std::optional<IcomMemoryChannel> decodeMemory(
return std::nullopt;
}

memory.split = payload.size() == static_cast<std::size_t>(layout.splitBytes);
// The IC-7300MK2 always returns the second 4..17 block documented by its
// CI-V guide, even when Split is OFF. Its byte-3 SPLIT flag, decoded
// below, is therefore the authority; treating the 47-byte reply length as
// Split made every live channel display-only and prevented spot recall.
// The older dialects retain their established variable-length contract.
memory.split = dialect != MemoryDialect::Ic7300Mk2
&& payload.size() == static_cast<std::size_t>(layout.splitBytes);
const std::optional<std::uint64_t> frequency = decodeFreqExact(
payload.subspan(static_cast<std::size_t>(layout.frequencyOffset), kFreqBytes),
kFreqBytes);
Expand Down Expand Up @@ -217,7 +223,12 @@ std::optional<IcomMemoryChannel> decodeMemory(
}
memory.split = memory.split || ((selectByte >> 4) != 0);
}
memory.recallable = mode.mode.has_value() && !memory.split && duplex != 3;
// AetherSDR recalls the RX side of a stored channel onto the active slice.
// Split/RPS metadata is useful provenance, but it must not make a memory
// impossible to navigate to: the local database already has a complete,
// neutral RX frequency and mode. Modes with no neutral representation
// (currently DV/DD) remain display-only.
memory.recallable = mode.mode.has_value();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker 1 — the deleted guard's cases were never given the decode they need. The comment above claims "the local database already has a complete, neutral RX frequency", but IcomMemoryChannel has no field for the second 4..17 block (it is parsed for nothing), and in the backend duplex == 3 falls through the default: arm to offsetDir = "simplex". Recalling an RPS/split row therefore tunes the first-block frequency and pushes DUP=simplex at the live radio — clearing the stored split and, on TX, keying the wrong frequency. Your own coverage-limits section lists split/reverse-split recall as untested, and the changed test row asserts the flag, not which frequency recalls.

Until the second block is decoded (or its semantics evidenced à la docs/data/icom-ic9700-fm-repeater-*), keep duplex == 3 and split rows display-only:

Suggested change
memory.recallable = mode.mode.has_value();
memory.recallable = mode.mode.has_value() && !memory.split && duplex != 3;


if (layout.dtcsOffset >= 0) {
const std::optional<RepeaterToneRegister> dtcs = decodeRepeaterToneRegister(
Expand Down
Loading
Loading