Skip to content

Commit fe614b0

Browse files
jensenpatrfoust
andauthored
feat(aprs): add WIDE1-1 fill-in with explicit TX arming — Principle VI.
Add the AetherModem WIDE1-1 fill-in tab, model-owned engine and beacon, destination-aware duplicate suppression, traffic counters, and automation controls. Require explicit 1200-baud session arming. Bound all modem producers to one 64-frame queue and cancel digi-origin pending/active work on disarm while preserving unrelated traffic. Cover the lifecycle with socket-free regression tests and mutation checks. Implements Phase 1 of #3571; wide-area and other Phase-2 work remains tracked there. Co-authored-by: jensenpat <patjensen@gmail.com> Co-authored-by: Robbie Foust <rfoust@gmail.com>
1 parent 7bd873d commit fe614b0

22 files changed

Lines changed: 1782 additions & 83 deletions

CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,7 @@ set(CORE_SOURCES
928928
src/core/aprs/AprsMessenger.cpp
929929
src/core/aprs/AprsBeacon.cpp
930930
src/core/aprs/AprsSettings.cpp
931+
src/core/aprs/AprsFillInDigipeater.cpp
931932
)
932933

933934
if(APPLE)
@@ -949,6 +950,7 @@ elseif(UNIX)
949950
endif()
950951

951952
set(MODEL_SOURCES
953+
src/models/AprsDigipeaterModel.cpp
952954
src/models/Nr2SettingsModel.cpp
953955
src/models/Rn2SettingsModel.cpp
954956
src/models/RadioModel.cpp
@@ -1048,6 +1050,7 @@ set(GUI_SOURCES
10481050
src/gui/NetworkDiagnosticsDialog.cpp
10491051
src/gui/SystemInfoDialog.cpp
10501052
src/gui/Ax25HfPacketDecodeDialog.cpp
1053+
src/gui/AprsRateGraph.cpp
10511054
src/gui/DStarAccessibility.cpp
10521055
src/gui/DStarModemPage.cpp
10531056
src/gui/AprsMessagesDialog.cpp

ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,9 @@ Substantial features requested on the
146146

147147
**Packet / APRS / mapping** (building on the new map engine + AFSK demod)
148148

149-
- **APRS digipeater** tab (MVP: WIDE1-1 fill-in)
150-
([#3571](https://github.com/aethersdr/AetherSDR/issues/3571)).
149+
- **Digipeater Phase 2**: wide-area WIDEn-N/SSn-N, N trapping, viscous/direct-only
150+
operation, and tiered beacons ([#3571](https://github.com/aethersdr/AetherSDR/issues/3571)).
151+
The current MVP covers 1200-baud WIDE1-1 fill-in only. APRS-IS is separate scope.
151152
- **Live NEXRAD / weather-radar tile overlay** on the map
152153
([#3574](https://github.com/aethersdr/AetherSDR/issues/3574)).
153154
- **IQ-stream transmission over TCI** for CW/RTTY skimmers

docs/MODEM.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,8 @@ in `AppSettings` (`AetherModemPms*` keys) across restarts; enabling the PMS turn
277277
the modem on. The bottom of the window is a slim status bar showing modem state,
278278
gain, and a compact packet-activity strip.
279279

280-
These layers are intentionally split so the planned APRS/AX.25 **digipeater** can
281-
reuse `Ax25`/`Ax25Connection` and the heard list directly.
280+
These layers are intentionally split so the APRS/AX.25 **fill-in digipeater**
281+
can reuse `Ax25` and the heard list directly.
282282

283283
## Open Work
284284

@@ -302,8 +302,21 @@ KISS-over-TCP, connected-mode AX.25, a Personal Mailbox System, and an **APRS
302302
client** (live station map, GPS beacon, two-way messaging on the AetherModem
303303
tab) are now implemented — see the sections above and the PSK Reporter map's
304304
shared Qt mapping engine, which the APRS map reuses. Out of scope remains
305-
**APRS-IS** (internet gateway) and a future **APRS/AX.25 digipeater**, which can
306-
reuse the `Ax25`/`Ax25Connection` primitives.
305+
**APRS-IS** (internet gateway). A **WIDE1-1 fill-in digipeater** now lives on the
306+
AetherModem **Digipeater** tab (`AprsDigipeaterModel`): it answers the first unused
307+
hop when it is `WIDE1-1` (optionally `MYCALL` or legacy `RELAY`), substitutes
308+
the station call with the H-bit set, and does not decrement `WIDE2-n`. The tab
309+
has a heard-message graph, a digipeat graph, a scrolling raw TNC-2 list, and
310+
configurable fill-in / beacon settings. Fill-in requires the shared **1200-baud
311+
VHF profile** and explicit arming each session. Configuration persists, but
312+
arming never restores from settings. Switching to 300 baud, disabling the modem,
313+
changing the attached slice, disconnecting, or application teardown disarms fill-in
314+
and cancels its queued and active TX. Other producers retain their own queued
315+
traffic when only fill-in is disabled. The shared queue admits at most 64 frames
316+
across all producers, dropping the oldest on overflow. Duplicate identity includes
317+
source/SSID, destination, and payload, independent of the received path.
318+
The counters describe repeat decisions, not proof of RF delivery. Wide-area
319+
`UITRACE WIDE` remains out of scope.
307320

308321
PMS follow-ups worth tracking:
309322

docs/architecture/aetherd-touchpoint-tags.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,5 +1330,11 @@
13301330
"note": "Capability-gated client for the ANAN calibration extension. Uses only IRadioBackend requests and replies; concrete calibration ownership, rate control and DSP stay below the backend seam.",
13311331
"split": "Client protocol adapter only; no vendor implementation dependency.",
13321332
"confidence": "high"
1333+
},
1334+
"models/AprsDigipeaterModel.h": {
1335+
"tag": "universal",
1336+
"note": "Session-local APRS fill-in policy and bounded modem queue; no backend-specific commands or transport ownership.",
1337+
"split": "",
1338+
"confidence": "high"
13331339
}
13341340
}

docs/architecture/aetherd-touchpoints.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine-design.md) §2, §10). One row per engine header the UI includes; converting a touchpoint means the UI reaches that surface through the versioned protocol instead of the header.
66

7-
**Totals:** 215 touchpoint headers (184 core, 31 models) — 215/215 tagged, 0/215 converted.
7+
**Totals:** 216 touchpoint headers (184 core, 32 models) — 216/216 tagged, 0/216 converted.
88

99
| Header | Includers | Tag | Status |
1010
|---|---:|---|---|
@@ -142,7 +142,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine
142142
| `core/SystemInventory.h` | 1 | ui-support — Startup and support-bundle inventory of host CPU, SIMD and RAM capabilities. Process diagnostics, not radio state. | unconverted |
143143
| `core/TciServer.h` | 3 | mixed(flex) — TCI WebSocket server for WSJT-X et al: protocol surface is canonical radio state, but audio/IQ rides Flex DAX | unconverted |
144144
| `core/TgxlConnection.h` | 2 | peripheral(4o3a) — Direct TCP client for the 4O3A Tuner Genius XL (port 9010, relay/autotune), reverse-engineered from the 4O3A management app — a standalone accessory transport, not SmartSDR. Not radio-family wire; a peripheral accessory, NOT behind the IRadioBackend radio seam (reclassified from vendor(flex), #4087 follow-up). | unconverted |
145-
| `core/ThemeManager.h` | 145 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted |
145+
| `core/ThemeManager.h` | 146 | ui-support — Qt token-based theming singleton (colors/fonts/QSS, theme files, editor hooks) — pure client GUI plumbing, no radio state. | unconverted |
146146
| `core/ThreadCpuRing.h` | 2 | ui-support — Short host-thread CPU history used by Runtime Monitor peak and sparkline presentation. Diagnostic UI support, not radio state. | unconverted |
147147
| `core/TimeFrameVoter.h` | 1 | universal — Shared AetherClock time-frame types plus confidence-weighted cross-frame bit voting over a sliding window. Map-agnostic pure DSP/logic — no Qt, no GUI, no vendor ties. | unconverted |
148148
| `core/TxKeyingMarker.h` | 6 | ui-support — QWidget property marker guarding TX-keying controls from the automation bridge; GUI-shell plumbing, no radio state. | unconverted |
@@ -194,6 +194,7 @@ Burndown manifest for the engine/UI decoupling ([RFC](../aetherd-headless-engine
194194
| `core/tnc/TncTerminal.h` | 1 | universal — Packet terminal session model (command/monitor); radio-agnostic operating feature. | unconverted |
195195
| `models/AetherClockModel.h` | 2 | universal — Thin first-class AetherClock model with full Q_PROPERTY coverage (protocol-serializable, QML-ready), mirroring engine state over queued connections. Owns no DSP and has no vendor ties — one of only three models already shaped the way the protocol will want them. | unconverted |
196196
| `models/AntennaGeniusModel.h` | 4 | peripheral(4o3a) — 4O3A Antenna Genius switch client — standalone accessory with its own UDP-broadcast discovery (port 9007) + direct TCP; connects by device IP/port independent of the radio, works with any radio. Not radio-family wire; a peripheral accessory, NOT behind the IRadioBackend radio seam (reclassified from vendor(flex), #4087 follow-up). | unconverted |
197+
| `models/AprsDigipeaterModel.h` | 1 | universal — Session-local APRS fill-in policy and bounded modem queue; no backend-specific commands or transport ownership. | unconverted |
197198
| `models/BandDefs.h` | 5 | universal — Static ARRL band plan table (edges, default freq/mode, GEN/WWV); canonical band-plan data, no vendor ties. | unconverted |
198199
| `models/BandPlanManager.h` | 9 | universal — Band-plan overlay data (segments/spots/license classes, region merge) from JSON; radio-agnostic canon | unconverted |
199200
| `models/BandSettings.h` | 5 | universal — Per-band save/restore of canonical state (freq/mode/filter/AGC/WNB/display range) — band memories, no vendor fields | unconverted |

docs/automation-bridge.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3519,6 +3519,34 @@ mailbox, and the terminal, so a headless soak box never has to open it.
35193519
checkbox actually took and returns `ok:false` if the modem refused (no audio
35203520
engine, no attached slice) rather than reporting success for work that did not
35213521
happen.
3522+
- **`modem digi`** / **`modem digi status`** — WIDE1-1 fill-in digipeater
3523+
snapshot (`enabled`, call, alias, dupe window, beacon fields, heard/repeated
3524+
counters, and the **current air rate** `baud` / `profileId`). The fill-in
3525+
engine is baud-agnostic; 300 Hz HF and 1200 Hz VHF share one modem profile
3526+
(`modem profile hf300|vhf1200`). Read-only.
3527+
- **`modem digi on` / `modem digi off`** — arm/disarm the fill-in. `on` ⚠️
3528+
keys the transmitter whenever a matching UI frame is heard, so it is refused
3529+
unless `AETHER_AUTOMATION_ALLOW_TX=1`. Verifies the checkbox actually took
3530+
(a missing digi callsign or a profile other than 1200 baud fails closed).
3531+
Fill-in starts disarmed on every launch; configuration and beacon preference
3532+
persist, but TX authorization does not. Disabling fill-in cancels its pending
3533+
repeats/beacons and active TX without discarding other producers' packets.
3534+
Disabling the modem, changing the attached slice, disconnecting the radio,
3535+
or switching to 300 baud also disarms fill-in. Both Digi enable checkboxes
3536+
are TX-keying controls for generic automation invocations.
3537+
- **`modem digi beacon`** ⚠️ — fire one fill-in-style position beacon now
3538+
(same `AETHER_AUTOMATION_ALLOW_TX=1` rail). Fails if there is no callsign or
3539+
no GPS/manual position.
3540+
3541+
```json
3542+
→ {"cmd":"modem","action":"digi","value":"status"}
3543+
← {"ok":true,"baud":1200,"profileId":"Vhf1200",
3544+
"digi":{"enabled":true,"call":"KI6BCJ-7","alias":"WIDE1-1",
3545+
"alsoMyCall":true,"alsoRelay":false,"dupeWindowSecs":30,
3546+
"beaconEnabled":false,"beaconIntervalMin":15,
3547+
"heard":12,"repeated":3,"droppedDupe":1,"droppedNoMatch":8,
3548+
"droppedOwn":0,"baud":1200,"profileId":"Vhf1200"}}
3549+
```
35223550

35233551
The `demod` block is what separates "no frames because the band is dead" from
35243552
"no frames because the audio tap never started": `receiveGateOpen` plus a
@@ -3840,7 +3868,8 @@ the airtime model predicts for the current profile and paclen; comparing it with
38403868
`rtt.avgMs` is how you tell whether the model matches the air. See
38413869
[`HFMODEM.md`](HFMODEM.md).
38423870

3843-
Bare-line forms: `modem profile hf300`, `modem on`, `link status`,
3871+
Bare-line forms: `modem profile hf300`, `modem on`, `modem digi status`,
3872+
`modem digi on`, `modem digi beacon`, `link status`,
38443873
`link mycall KI6BCJ-7`, `link connect N0BBS-1 via WIDE1-1`, `link pms on`.
38453874

38463875
---
@@ -4209,7 +4238,7 @@ still a separate radiocert task.
42094238
| `dss` || dss <snapshot\|reset\|inject\|scrollback\|live> [pan] [args] |
42104239
| `streams` || streams [radio\|inventory\|resync\|refresh\|reset] — stream diagnostics |
42114240
| `devices` || devices <list\|ulanzi\|ulanzi-start\|ulanzi-stop> — external-device diagnostics and lifecycle control |
4212-
| `modem` | `aethermodem` | modem <status\|profile hf300\|profile vhf1200\|on\|off\|preamble <flags\|auto>> — AetherModem demod profile, TXDELAY, RX tap, and decoder health |
4241+
| `modem` | `aethermodem` | modem <status\|profile hf300\|profile vhf1200\|on\|off\|preamble <flags\|auto>\|digi [status\|on\|off\|beacon]> — AetherModem demod, TXDELAY, RX tap, WIDE1-1 fill-in, and decoder health |
42134242
| `link` | `ax25` | link <status\|connect <call> [via <digi>]\|disconnect\|mycall <call>\|listen <call>\|alias <call>\|pms on\|off> — connected-mode AX.25 terminal + mailbox, with measured RTT vs configured T1 |
42144243
| `memprofile` || memprofile <snapshot\|start\|sample\|status\|report\|samples\|stop\|reset> [intervalMs maxSamples] |
42154244
| `tci` || tci start\|status\|stop\|send\|trace\|routes [@id] [rx=N] — TCI simulator (multi-client: @id names a client, rx=N its audio_start receiver) and protocol diagnostics |

src/core/AutomationServer.cpp

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2712,7 +2712,8 @@ namespace {
27122712
// (#4188 area 6). Some diagnostic verbs mix read and write actions, so the
27132713
// action must be checked as well as the canonical verb name. Everything else
27142714
// (drive/connect/capture/keying) is refused when m_readOnly is set.
2715-
bool isReadOnlyRequest(const QString& name, const QString& action)
2715+
bool isReadOnlyRequest(const QString& name, const QString& action,
2716+
const QString& value = {})
27162717
{
27172718
static const QSet<QString> kSafe = {
27182719
QStringLiteral("ping"), QStringLiteral("verbs"),
@@ -2751,7 +2752,16 @@ bool isReadOnlyRequest(const QString& name, const QString& action)
27512752
// `modem`/`link` mix introspection with actions that key the radio
27522753
// (link connect transmits a SABM), so only the status reads are safe here.
27532754
if (name == QLatin1String("modem") || name == QLatin1String("link")) {
2754-
return normalizedAction.isEmpty() || normalizedAction == QLatin1String("status");
2755+
if (normalizedAction.isEmpty() || normalizedAction == QLatin1String("status"))
2756+
return true;
2757+
// `modem digi` / `modem digi status` is introspection; `modem digi on`
2758+
// and `modem digi beacon` key the radio and stay gated.
2759+
if (name == QLatin1String("modem")
2760+
&& normalizedAction == QLatin1String("digi")) {
2761+
const QString v = value.trimmed().toLower();
2762+
return v.isEmpty() || v == QLatin1String("status");
2763+
}
2764+
return false;
27552765
}
27562766
if (name == QLatin1String("tci")) {
27572767
return normalizedAction == QLatin1String("status")
@@ -3452,7 +3462,7 @@ const std::vector<AutomationServer::VerbSpec>& AutomationServer::verbRegistry()
34523462
});
34533463

34543464
add("modem", {"aethermodem"},
3455-
"modem <status|profile hf300|profile vhf1200|on|off|preamble <flags|auto>> — AetherModem demod profile, TXDELAY, RX tap, and decoder health",
3465+
"modem <status|profile hf300|profile vhf1200|on|off|preamble <flags|auto>|digi [status|on|off|beacon]> — AetherModem demod profile, TXDELAY, RX tap, WIDE1-1 fill-in digipeater, and decoder health",
34563466
parseActionRest,
34573467
[](AutomationServer& s, A& a, QLocalSocket*) {
34583468
return s.doModemAutomation(QStringLiteral("modem"), a.action, a.value);
@@ -3873,7 +3883,7 @@ QJsonObject AutomationServer::handleLine(const QByteArray& line, QLocalSocket* s
38733883
// keying. Enforced HERE in the bridge (not the MCP client) so it can't be
38743884
// bypassed by talking to the socket directly. Uses the resolved canonical
38753885
// name so aliases are covered.
3876-
if (m_readOnly && !isReadOnlyRequest(spec->name, a.action)) {
3886+
if (m_readOnly && !isReadOnlyRequest(spec->name, a.action, a.value)) {
38773887
qCWarning(lcAutomation) << "read-only mode: refused" << spec->name;
38783888
return err(QStringLiteral("read-only mode: '") + spec->name
38793889
+ QStringLiteral("' is blocked. This bridge is observe-only "
@@ -11574,15 +11584,26 @@ QJsonObject AutomationServer::doModemAutomation(const QString& verb,
1157411584
// mailbox OFF never keys, so it stays ungated.
1157511585
const QString normalizedAction = action.trimmed().toLower();
1157611586
const QString normalizedValue = value.trimmed();
11577-
const bool keysTransmitter = verb == QLatin1String("link")
11578-
&& (normalizedAction == QLatin1String("connect")
11579-
|| normalizedAction == QLatin1String("disconnect")
11580-
|| (normalizedAction == QLatin1String("pms")
11581-
&& normalizedValue.toLower() == QLatin1String("on")));
11587+
const QString valueLower = normalizedValue.toLower();
11588+
const bool keysTransmitter =
11589+
(verb == QLatin1String("link")
11590+
&& (normalizedAction == QLatin1String("connect")
11591+
|| normalizedAction == QLatin1String("disconnect")
11592+
|| (normalizedAction == QLatin1String("pms")
11593+
&& valueLower == QLatin1String("on"))))
11594+
|| (verb == QLatin1String("modem")
11595+
&& normalizedAction == QLatin1String("digi")
11596+
&& (valueLower == QLatin1String("on")
11597+
|| valueLower == QLatin1String("enable")
11598+
|| valueLower == QLatin1String("beacon")));
1158211599
if (keysTransmitter && !m_txAllowed) {
11583-
return err(QStringLiteral("'link %1' keys the transmitter — set "
11600+
const QString what = (verb == QLatin1String("modem")
11601+
&& normalizedAction == QLatin1String("digi"))
11602+
? QStringLiteral("modem digi %1").arg(valueLower)
11603+
: QStringLiteral("link %1").arg(normalizedAction);
11604+
return err(QStringLiteral("'%1' keys the transmitter — set "
1158411605
"AETHER_AUTOMATION_ALLOW_TX=1 to allow")
11585-
.arg(normalizedAction));
11606+
.arg(what));
1158611607
}
1158711608
return m_modemAutomationHandler(verb, normalizedAction, normalizedValue);
1158811609
}

0 commit comments

Comments
 (0)