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
31 changes: 23 additions & 8 deletions docs/architecture/aetherd-icom-civ-backend-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -702,14 +702,29 @@ 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. Repeat Sync
refreshes tuning fields while preserving the name, owner and group assigned
at first import or edited locally. Clearing a native channel removes its
matching imported row. Split/RPS/DV/DD records remain display-only.
Existing experimental imports with incorrect recallability need one explicit
Sync: they did not retain enough split metadata for a safe load-time repair.
Loading an existing bank never rewrites it. Ordinary local memories remain
schema 1; saves containing native recall fields use schema 2 so an older
writer cannot erase recallability, DTCS state or provenance. Downgrading
after such a save requires a compatible build or a pre-Sync settings backup.
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
2 changes: 1 addition & 1 deletion docs/architecture/aetherd-touchpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine
| `core/aprs/AprsStationList.h` | 1 | universal — Heard-APRS-station model (calls/positions/last-heard); radio-agnostic spot-like data. | unconverted |
| `core/backends/ConnectionSharingPolicy.h` | 2 | universal — Fail-closed discovery-time policy for whether a busy radio family permits another client. Canonical cross-family connection safety pending capability descriptors. | unconverted |
| `core/backends/IRadioBackend.h` | 1 | universal — THE radio seam (RFC §5.5) — the canonical intent verbs, typed deltas and normalized signals every family implements. Universal by definition: the UI reaching this header is the seam working as designed, not coupling. Everything below it in core/backends/<family>/ is family-private. | unconverted |
| `core/backends/RadioCapabilities.h` | 5 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted |
| `core/backends/RadioCapabilities.h` | 6 | universal — Backend-neutral capability descriptor consumed above the radio seam. Universal by definition; family implementations populate it. | unconverted |
| `core/backends/anan/AnanDiscovery.h` | 2 | vendor(anan) — openHPSDR Protocol 2 discovery and ANAN-G2 identity handling. Family-specific discovery belongs below the ANAN backend seam. | unconverted |
| `core/backends/anan/AnanSettings.h` | 1 | ui-support — Client-side connection and ADC configuration for the ANAN backend. Persisted setup plumbing, not live radio state. | unconverted |
| `core/backends/anan/P2Protocol.h` | 1 | vendor(anan) — openHPSDR Ethernet Protocol 2 wire primitives for the ANAN-G2 backend. Family protocol code belongs below the radio seam. | unconverted |
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 @@ -100,8 +100,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
33 changes: 26 additions & 7 deletions src/core/LocalMemoryBank.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ void LocalMemoryBank::load()
return;
}

// Loading never guesses recallability or upgrades an existing document.
// Experimental Icom imports did not preserve split/RPS metadata, so only
// an explicit Sync with the corrected codec can safely repair those rows.

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 +152,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 Expand Up @@ -297,19 +314,20 @@ void LocalMemoryBank::scheduleSave()
m_saveTimer.start();
}

void LocalMemoryBank::flush()
bool LocalMemoryBank::flush()
{
m_saveTimer.stop();
if (!m_dirty)
return;
if (!m_dirty) {
return true;
}

// Not writable means load() could not understand the file — a version this
// build cannot read, a foreign format id, or JSON it could not parse.
// Writing would destroy it.
if (!m_writable) {
qCWarning(lcProtocol).noquote()
<< "LocalMemoryBank: refusing to overwrite an unreadable bank";
return;
return false;
}

// Somebody else wrote the document since we read it.
Expand All @@ -328,7 +346,7 @@ void LocalMemoryBank::flush()
"the memory panel to pick up the other changes.");
qCWarning(lcProtocol).noquote() << "LocalMemoryBank:" << m_lastError;
emit saveFailed(m_lastError);
return; // stays dirty
return false; // stays dirty
}

// savedAt uses millisecond precision: it doubles as the foreign-write
Expand All @@ -341,14 +359,14 @@ void LocalMemoryBank::flush()
if (!AppSettings::instance().setRadioFeature(
LocalMemoryStore::documentFamily(), QString(),
LocalMemoryStore::documentFeature(),
LocalMemoryStore::kFormatVersion, envelope)) {
LocalMemoryStore::formatVersionFor(m_entries), envelope)) {
m_lastError = QStringLiteral("the settings store refused the write");
qCWarning(lcProtocol).noquote()
<< "LocalMemoryBank: save failed —" << m_lastError;
emit saveFailed(m_lastError);
// Stay dirty: the next edit (or flush) retries. A transient failure
// must not cost the operator every channel they saved since.
return;
return false;
}

m_dirty = false;
Expand All @@ -358,6 +376,7 @@ void LocalMemoryBank::flush()
m_seenSavedAt = savedAt;
qCDebug(lcProtocol).noquote()
<< "LocalMemoryBank: saved" << m_entries.size() << "memories";
return true;
}

void LocalMemoryBank::rememberDocumentState()
Expand Down
8 changes: 7 additions & 1 deletion 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 All @@ -90,7 +94,9 @@ class LocalMemoryBank : public QObject {

// Write now if anything is pending. Called on disconnect and at teardown so
// the debounce window can never be the reason an edit is lost.
void flush();
// Returns false when pending edits could not be committed. Sync completion
// must not confuse a decoded radio snapshot with a durably saved bank.
bool flush();

// Last file-write failure, empty when the last save succeeded. Surfaced so a
// read-only config dir shows up as something other than memories that
Expand Down
36 changes: 35 additions & 1 deletion 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 All @@ -69,12 +89,26 @@ MemoryEntry entryFromJson(const QJsonObject& o)

} // namespace

int LocalMemoryStore::formatVersionFor(const QMap<int, MemoryEntry>& memories)
{
for (const MemoryEntry& memory : memories) {
if (!memory.channel.isEmpty() || !memory.importSource.isEmpty()
|| !memory.importKey.isEmpty() || memory.nativeFilter != 0
|| memory.dataMode != 0 || memory.rxToneValue != 0.0
|| memory.dtcsCode != 23 || memory.dtcsTxReverse
|| memory.dtcsRxReverse || !memory.recallable) {
return kFormatVersion;
}
}
return 1;
}

QByteArray LocalMemoryStore::serialize(const QMap<int, MemoryEntry>& memories,
const QString& savedAtIso)
{
QJsonObject root;
root["format"] = kFormatId;
root["version"] = kFormatVersion;
root["version"] = formatVersionFor(memories);
if (!savedAtIso.isEmpty())
root["savedAt"] = savedAtIso;
root["savedBy"] = "AetherSDR";
Expand Down
15 changes: 9 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": 1 or 2,
// "savedAt": "2026-07-29T14:00:00Z",
// "savedBy": "AetherSDR",
// "memories": [ { "index": 0, ...MemoryEntry... } ]
Expand All @@ -35,7 +35,10 @@ namespace AetherSDR {
// bank is sparse.
class LocalMemoryStore {
public:
static constexpr int kFormatVersion = 1;
static constexpr int kFormatVersion = 2;
Comment thread
rfoust marked this conversation as resolved.
// Keep ordinary client memories readable by version-1 builds. Imported
// recall state needs version 2: older writers would drop safety metadata.
static int formatVersionFor(const QMap<int, MemoryEntry>& memories);
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/IRadioBackend.h
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,8 @@ class IRadioBackend : public QObject {
void memoryChanged(const MemoryDelta& delta);
void memoryRefreshStarted(int total);
void memoryRefreshProgress(int completed, int total);
// All deltas for this sweep precede completion. This reports radio reads;
// RadioModel combines it with import/save results for its UI-facing signal.
void memoryRefreshFinished(bool success, int completed, int total);

// Normalized profile status (aetherd RFC 2.3 — RadioModel residual). The
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
Loading
Loading