Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
15 changes: 11 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,10 +470,17 @@ selected at connect time by a `family` string through `makeBackend()`:

Step 3 is in progress: the normative v1 envelope contract, bounded codec,
observe-only local handshake/capability service, and a QtWidgets-free
`aetherd` skeleton have landed. Typed model resources, subscriptions,
authenticated non-TX control, and the desktop adapter have not; UI code still
consumes models directly, and that remains correct. No protocol TX method is
advertised before the step-4 arbiter exists.
`aetherd` skeleton have landed. The typed observe-only `server`,
`radioSession`, `slice`, and `panadapter` resources now publish through
`RadioResourceAdapter`; `resource.get` plus atomic snapshot/event
`resource.subscribe`/`resource.unsubscribe`, per-resource revisions, bounded
coalescing/session resync, and an independent local-socket hard disconnect cap
are live over the current-user local transport.
Meters, read-only transmit state, authenticated non-TX control, and the desktop
adapter have not landed; UI code still consumes models directly, and that
remains correct. New resource fields belong in the adapter and the versioned
catalogue, never in a transport or via QObject reflection. No protocol TX
method is advertised before the step-4 arbiter exists.

**Backends that demodulate in-process double-feed the sink if you let
them.** `IRadioBackend::audioFrameReady` has two possible routes to
Expand Down
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,10 @@ set(AETHER_SETTINGS_SOURCES

set(CORE_SOURCES
src/core/control/ControlProtocolCodec.cpp # aetherd v1 bounded JSON envelopes
src/core/control/ControlResourceStore.cpp # typed snapshots and revisions
src/core/control/ControlSession.cpp # subscriptions and bounded event queues
src/core/control/ControlService.cpp # aetherd v1 observe-only dispatch
src/core/control/RadioResourceAdapter.cpp # normalized model resources
src/core/control/LocalControlServer.cpp # current-user local transport
src/core/backends/MemoryWireCodec.cpp # memory kv-set decode, shared by Flex + local bank
src/core/backends/flex/FlexBackend.cpp # aetherd RFC step 2.2 (§5.5)
Expand Down
15 changes: 10 additions & 5 deletions docs/aetherd-control-protocol-v1-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ schema test before it is exported. Credentials, backend pointers, raw vendor
messages, filesystem paths, unbounded log text, and other clients' private
state are never resources.

The schemas implemented by the first observe-only slice are fixed in
[`aetherd-control-resource-v1-catalogue.md`](aetherd-control-resource-v1-catalogue.md).

`resource.subscribe` accepts explicit resource selectors and returns an atomic
baseline:

Expand All @@ -200,11 +203,13 @@ baseline:
}
```

The service registers the subscription, captures the snapshot, and queues all
changes after the returned `sequence` as one main-thread operation. Therefore
there is no snapshot/event gap. Reconnect creates a new protocol session;
clients resubscribe and replace their cache from a fresh snapshot. V1 does not
promise event replay across connections.
The service returns the last event sequence already drained to the transport,
registers the subscription, and captures the snapshot as one main-thread
operation. Events still pending for existing subscriptions retain greater
sequences, and newly generated changes advance beyond them. Therefore there is
no snapshot/event gap or duplicate baseline sequence. Reconnect creates a new
protocol session; clients resubscribe and replace their cache from a fresh
snapshot. V1 does not promise event replay across connections.

High-rate spectrum, waterfall and audio payloads are not embedded in these
control JSON events. Their later stream contract must use bounded binary
Expand Down
166 changes: 166 additions & 0 deletions docs/aetherd-control-resource-v1-catalogue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# AetherD control protocol v1 — observe-only resource catalogue

This catalogue fixes the schema implemented by the first read-only Stage 3
slice of RFC #3849. It supplements
[`aetherd-control-protocol-v1-design.md`](aetherd-control-protocol-v1-design.md);
the envelope, limits, errors, authentication, and TX rules in that document
remain normative.

Only the four resource types below exist in this slice. `meter` and
`transmitState` remain unimplemented. No method in this catalogue mutates a
model or can reach a radio backend intent.

## Resource identities and selectors

An exact identity has one of these shapes:

```json
{"type":"server"}
{"type":"radioSession","id":"radio-1"}
{"type":"slice","radioSession":"radio-1","id":"0"}
{"type":"panadapter","radioSession":"radio-1","id":"0x40000000"}
```

`resource.get` requires an exact identity. `resource.subscribe` also accepts
an omitted `id` as an all-current-and-future selector for `radioSession`,
`slice`, or `panadapter`; `slice` and `panadapter` still require
`radioSession`. Unknown fields and unsupported resource types are rejected.

## Methods

All methods require the negotiated session ID. The initial current-user local
endpoint grants `observe` to every negotiated session; because no other session
type exists yet, this slice has no separate per-request grant branch. Explicit
per-session grant mapping and checks arrive with authentication before another
grant or remote session is exposed.

### `resource.get`

Parameters:

```json
{"resource":{"type":"slice","radioSession":"radio-1","id":"0"}}
```

Result:

```json
{
"resource":{"type":"slice","radioSession":"radio-1","id":"0"},
"revision":3,
"value":{}
}
```

The complete typed value occupies `value`. A missing exact identity returns
`resource.not_found`.

### `resource.subscribe`

Parameters contain 1–64 selectors:

```json
{"resources":[{"type":"slice","radioSession":"radio-1"}]}
```

The result contains a session-local subscription ID, the last session event
sequence already drained to the transport, and the complete baseline matching
those selectors. Registration and snapshot capture execute as one main-thread
operation. Events still pending for existing subscriptions retain sequences
greater than the returned boundary, and newly generated events advance beyond
them, so an event delivered after the baseline cannot leave a snapshot/event
gap or reuse the baseline sequence.

### `resource.unsubscribe`

Parameters are `{"subscription":"sub-1"}`. Success returns the same ID and
`"removed":true`. An unknown ID returns `resource.not_found`.

## Events, revisions, and resync

`resource.changed` carries the complete new value. `resource.removed` carries
the identity and its next revision but no value. Revisions come from one
store-wide monotonic counter. They are therefore monotonic per identity and
survive removal/recreation. A revision is consumed only when a canonical value
changes or a live identity is removed, but an identity's revisions need not be
consecutive or begin at one.

Event `sequence` is monotonic within one protocol session. Pending events for
the same resource coalesce to the newest sequence, revision, and complete value.
Sequences may therefore have gaps; they never move backward.

If a session's bounded event queue cannot retain its subscribed state, the
service clears that session's subscriptions and emits:

```json
{
"v":1,
"sessionId":"...",
"event":"resource.resyncRequired",
"sequence":42,
"subscriptionsInvalidated":true
}
```

The client must call `resource.subscribe` again and replace its cache from the
fresh baseline. The current-user local transport also has an independent hard
socket-output cap. A client whose operating-system socket buffer is already at
Comment thread
ten9876 marked this conversation as resolved.
Outdated
Comment thread
ten9876 marked this conversation as resolved.
Outdated
that cap can be disconnected before a queued resync notice is written; after
reconnecting it must establish a new session and baseline.

## Resource values

### `server`

- `name`: server product name.
- `buildVersion`: AetherSDR build version.
- `protocolVersions`: supported protocol versions.
- `health`: bounded service health token.
- `localTransport`: `idle`, `listening`, or `stopped`. `idle` and `stopped`
describe in-process lifecycle state before or after socket availability; a
protocol client can query this resource only while the value is `listening`.

No endpoint path, process environment, hostname, or filesystem value is
exported.

### `radioSession`

- `id`, `connected`, `family`.
- `identity`: `name`, `model`, `serial`, `version`, `manufacturer`.
- `capabilities`:
- `maxSlices`, `maxPanadapters`, `sampleRatesHz`;
- `tuningRangeHz` with `minimum` and `maximum`;
- `declaredBands`, each with `name`, `lowHz`, and `highHz`;
- `canTransmit`, `maximumTransmitWatts`, `hasTuner`, `hasAmplifier`;
- `extensions`, containing namespace names only, never extension payloads.

`canTransmit` is observation only. It does not advertise a protocol TX method
or grant and cannot key a radio.

### `slice`

- `id`, `letter`, `panadapterId`, `owned`.
- `frequencyHz`, `mode`, `filter.lowHz`, `filter.highHz`.
- `active`, `txSlice`, `locked`.
- `audio.gain`, `audio.pan`, `audio.muted`.
- `receive.antenna`, `receive.rfGain`.
- `receive.agc.mode`, `receive.agc.threshold`, `receive.agc.offLevel`.
- `receive.squelch.enabled`, `receive.squelch.level`.

Values come from `SliceModel`; radio/backend status remains authoritative.

### `panadapter`

- `id`.
- `centerHz`, `centerKnown`, `bandwidthHz`.
- `dbmRange.minimum`, `dbmRange.maximum`.
- `bandwidthLimitsHz.minimum`, `bandwidthLimitsHz.maximum`; zero means the
backend has not reported a limit.
- `receive.antenna`, `receive.rfGain`.
- `displayCadence.fps`, `displayCadence.averageFrames`.
- `displayCadence.weightedAverage`, `weightedAverageKnown`.
- `displayCadence.waterfallRate`; `-1` means the backend has not reported a
value, otherwise this is the normalized 1–100 rate, not milliseconds.

FFT bins, waterfall rows, audio, and other high-rate data never enter these
JSON resources; they belong to the later bounded binary data plane.
7 changes: 7 additions & 0 deletions src/aetherd/main.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#include "core/control/LocalControlServer.h"
#include "core/control/RadioResourceAdapter.h"
#include "models/RadioSession.h"

#include <QCommandLineParser>
#include <QCoreApplication>
Expand All @@ -22,7 +24,12 @@ int main(int argc, char* argv[])
parser.addOption(socketOption);
parser.process(app);

AetherSDR::RadioSession radioSession;
radioSession.setSessionId(1);
AetherSDR::control::LocalControlServer server;
[[maybe_unused]] AetherSDR::control::RadioResourceAdapter resources(
&radioSession.radioModel(), &server.resourceStore(),
QStringLiteral("radio-1"));
if (!server.listen(parser.value(socketOption))) {
QTextStream(stderr) << "aetherd: cannot listen on local socket '"
<< parser.value(socketOption) << "'\n";
Expand Down
93 changes: 93 additions & 0 deletions src/core/control/ControlResourceStore.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#include "ControlResourceStore.h"

namespace AetherSDR::control {

QString ResourceAddress::key() const
{
return type + QChar(0x1f) + radioSession + QChar(0x1f) + id;
}

QJsonObject ResourceAddress::toJson() const
{
QJsonObject object{{QStringLiteral("type"), type}};
if (!radioSession.isEmpty()) {
object.insert(QStringLiteral("radioSession"), radioSession);
}
if (!id.isEmpty()) {
object.insert(QStringLiteral("id"), id);
}
return object;
}

bool ResourceSelector::matches(const ResourceAddress& address) const
{
return type == address.type
&& (radioSession.isEmpty() || radioSession == address.radioSession)
&& (id.isEmpty() || id == address.id);
}

QJsonObject ResourceSnapshot::toJson() const
{
return {{QStringLiteral("resource"), resource.toJson()},
{QStringLiteral("revision"), static_cast<qint64>(revision)},
{QStringLiteral("value"), value}};
}

ControlResourceStore::ControlResourceStore(QObject* parent)
: QObject(parent)
{
}

bool ControlResourceStore::upsert(
const ResourceAddress& address, const QJsonObject& value)
{
const QString resourceKey = address.key();
const auto current = m_resources.constFind(resourceKey);
if (current != m_resources.constEnd() && current->value == value) {
return false;
}

const quint64 revision = ++m_lastRevision;
const ResourceSnapshot next{address, revision, value};
m_resources.insert(resourceKey, next);
emit resourceChanged(next);
return true;
}

bool ControlResourceStore::remove(const ResourceAddress& address)
{
const QString resourceKey = address.key();
if (m_resources.remove(resourceKey) == 0) {
return false;
}
const quint64 revision = ++m_lastRevision;
emit resourceRemoved(address, revision);
return true;
}

std::optional<ResourceSnapshot> ControlResourceStore::get(
const ResourceAddress& address) const
{
const auto found = m_resources.constFind(address.key());
if (found == m_resources.constEnd()) {
return std::nullopt;
}
return *found;
}

QList<ResourceSnapshot> ControlResourceStore::snapshot(
const QList<ResourceSelector>& selectors) const
{
QList<ResourceSnapshot> result;
for (auto it = m_resources.constBegin(); it != m_resources.constEnd(); ++it) {
for (const ResourceSelector& selector : selectors) {
if (selector.matches(it->resource)) {
result.append(*it);
break;
}
}
}
return result;
}

} // namespace AetherSDR::control
Loading
Loading