Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(streamdeck): slice targeting, connection status, and radio-sourced key values - #4514

#4514
Open
milenjb wants to merge 2 commits into
aethersdr:mainaethersdr/AetherSDR:mainfrom
milenjb:fix/streamdeck-slice-targetingmilenjb/AetherSDR:fix/streamdeck-slice-targetingCopy head branch name to clipboard
Open

feat(streamdeck): slice targeting, connection status, and radio-sourced key values#4514
milenjb wants to merge 2 commits into
aethersdr:mainaethersdr/AetherSDR:mainfrom
milenjb:fix/streamdeck-slice-targetingmilenjb/AetherSDR:fix/streamdeck-slice-targetingCopy head branch name to clipboard

Conversation

@milenjb

@milenjb milenjb commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #4513.

Makes the bundled Elgato Stream Deck plugin slice-aware and gives it feedback
on the controller itself. Every command previously addressed trx0, so the
plugin only ever drove slice A, and every key showed a static label from the
manifest — so an operator could neither point the deck at the slice they were
using nor see any radio value without looking at the AetherSDR window.

Headline changes:

  • Selectable slice targeting — a Slice Target action cycling
    TRX0 → TX → ACTIVE, applied to mode, DSP, squelch, lock, RIT/XIT, split,
    slice volume, slice mute and the VFO dial. Default stays TRX0, so an existing
    deck behaves exactly as before.
  • TCI connection status on the controller, first in the action list — the
    most common "the plugin does nothing" cause was previously invisible.
  • Live values on keys — RF power, tune power, slice volume, tune state,
    target slice, mute-all — sourced from the radio, showing until the radio
    has reported rather than a fabricated default.
  • Tune power dial, alongside the existing VFO, RF power and volume
    encoders.
  • Actions grouped into categories (Status, TX, Slice, Frequency, Modes,
    Audio, DSP, DVK, Dials) instead of one flat list, plus the CWR, SAM, NFM and
    RTTY modes the server supports but the plugin never exposed.

Fixed in passing, each found during hardware testing: TUNE could not be
switched off (no tune: broadcast exists, so the plugin's flag never changed);
the FT8 action keyed SSB (tciToSmartSDR maps unknown names to a USB default
rather than rejecting them); per-slice state was cross-contaminated because the
trx argument was discarded when parsing; the VFO dial stalled mid-spin fighting
inbound echoes; keys showed stale values after being added to a deck.

No AetherSDR/TCI changes — this is plugin-only.

Behaviour changes worth review

  • PTT/MOX/TUNE now address the TX slice rather than trx0.
    TciProtocol::cmdTrx reassigns the TX slice before keying, so the previous
    hardcoded trx:0 could move transmit to another band and antenna on a
    keypress. Verified on air: with transmit on slice B and the target set to
    TRX0, PTT keys slice B and transmit does not move.

  • The FT8 action is removed, because it was broken rather than redundant.
    TciProtocol::tciToSmartSDR maps unrecognised names to a USB default
    instead of rejecting them, and "ft8" has never appeared in that map in any
    commit — so the button took the slice out of a digital mode and put it on
    SSB. Measured on a FLEX-6600, starting from DIGU:

    modulation:0,digu           slice in DIGU
    rx_filter_band:0,0,3000     DIGU filter shape
    modulation:0,ft8            ← notification path, echoing what was sent
    modulation:0,usb            ← model path, what the slice actually became
    rx_filter_band:0,100,2800   USB filter shape
    

    FT8 is not a modulation, it is an operating convention on DIGU — so
    repairing the action would only have duplicated the existing DIGU button.
    It was dropped rather than shipping one function twice under two names.
    Existing decks with an FT8 key will show an unknown action. Trivial to
    reinstate as a DIGU-mapped preset if you would rather keep the name.

    Worth noting independently of this action: any unrecognised modulation
    name silently keys USB
    rather than being rejected, so a future typo in any
    client fails silently and on the air.

  • Key labels update at runtime where they were previously static.

Design note: no per-slice-index target

Deliberately omitted rather than overlooked. trx_count is sent only in the
init burst with no slice add/remove events, and
TciProtocol::resolveSliceForTrx falls back to the first slice for an
out-of-range trx — so a numbered picker built on a stale count would silently
act on the wrong receiver, with no error. Confirmed on hardware: a session
opened with one slice reported trx_count:1 and never revised it while two
further slices came up and broadcast as trx 1 and trx 2. TX and ACTIVE are
broadcast on every change, so they cannot go stale. The rationale is recorded
in the TARGET_MODES comment so it is not "fixed" later.

Constitution principle honored

Principle II — The Radio Is Authoritative On Live State. Displayed values
come from the radio, never from a remembered or invented default: a key shows
until the radio reports. Where a command has no status echo (tune: has no
broadcast at all) the plugin updates optimistically and then reconciles — a
trx:...,false supersedes the optimistic tune flag, so stopping TUNE from the
AetherSDR window is picked up. The dial echo guard exists to satisfy the same
principle's prohibition on the command and truth paths forming a feedback loop:
inbound echoes were overwriting ticks accumulated mid-spin, so the dial fought
the radio and stalled.

Principle VI — AetherSDR Never Transmits Without Operator Intent. PTT, MOX
and TUNE are pinned to the slice that already holds transmit, never the
user-selected target, because cmdTrx reassigns the TX slice before keying —
so a keypress could otherwise put a signal out on a band and antenna the
operator did not choose.

Test plan

  • Local build passes (cmake --build build) — unaffected; no C++ changes
  • Behavior verified on a real radio
  • Existing tests pass (CI)
  • Reproduction steps documented if user-reported bug

Automated tests — the first for any bundled plugin

The three plugins had no tests at all: the Elgato package.json carried only
the npm stub, and streamdeck-plugins.yml ran on release tags to zip
directories, so a syntax error in plugin.js would have shipped in a release.

Adds test.js — 13 tests, run with npm test. No new dependencies: it
uses only node:test and node:assert, so the plugin keeps the "no build
step, no npm dependencies" property in its own header, and ws remains the
single runtime dependency. ws is already vendored, so CI needs no install
step. The workflow now also runs on PRs touching plugins/**, and test.js is
excluded from the packaged bundle.

Each behavioural test spawns the real plugin.js against fake Stream Deck and
TCI sockets, so the shipped file is what runs. plugin.js gained one
testability change: the TCI port may be overridden with AETHERSDR_TCI_PORT,
defaulting to 50001 — without it a test would have to bind the real port and
would be answered by, or collide with, a live AetherSDR.

Covered, each derived from a defect actually hit during this work:

test what it pins
no dead buttons every manifest action reaches the radio when pressed
unique UUIDs, all categorised manifest hygiene
no action count in Description the prose that had already gone stale
TX-slice pinning PTT/MOX/TUNE never key the selected target
slice targeting TRX0 / TX / ACTIVE each address the right slice
volume read shape never emits the SET-shaped volume:
malformed records no NaN labels, no NaN trx reaching the radio
radio-sourced values keys show the radio's value, not a default
late-added key painted, and every key gets the redraw nudge
meter traffic rx_smeter causes no repaint storm
tune power dial drives tune_drive, never drive
dial echo guard ignores echoes mid-spin, accepts them once settled
TUNE resync toggles off, and recovers when stopped at the radio

The suite was mutation-checked rather than assumed: reverting the TX-slice
pinning, removing the title dedup, and dropping the trx boundary guard each
make the corresponding test fail.

Verified on Linux + OpenDeck + Mirabox N3EN, against a FLEX-6600
(firmware 4.2.20.41343) running AetherSDR 26.7.4.1, with TCI traffic
captured throughout. Thirteen groups:

area result
A install / connection, incl. reconnect pass
B state authority — no fabricated values pass
C slice targeting across three slices pass
D transmit safety pass
E TUNE toggle, incl. stopping from the GUI pass
F bands and frequency stepping pass
G all four dials, incl. cross-talk check pass
H modes pass
I audio, master and per-slice pass
J DSP pass
K split fails server-side, see below
L adding/removing actions, persistence pass
M stability, incl. WSJT-X alongside pass

Selected evidence:

  • Transmit safety (D). With transmit on slice B and GUI focus on slice C,
    every PTT/MOX emitted trx:1,true with tx_frequency matching B — twenty
    keypresses, never trx:0 or trx:2. The previous hardcoded trx:0 would
    have moved transmit to A on every press.
  • Slice targeting (C). rx_nb_enable:1, rx_nr_enable:2,
    rx_nr_enable:0 — DSP addressing three different slices as the target
    changed, with drive: and tune_drive: correctly following the TX slice
    instead. A later run exercised four slices.
  • Dials (G). All six VFO step sizes (10 Hz → 1 MHz), both power step sizes,
    and volume landing only on the dB-round-trip-safe percentages.
  • Stability (M). WSJT-X band-hopping 12m/15m/10m concurrently with the
    plugin, both clients working; a reconnect re-seeding every slice within a
    millisecond; ~20 rapid toggles with no runaway or stuck state.

Squelch does not act in CW and APF appears mode-gated similarly — expected
AetherSDR behaviour, not plugin faults.

A key added to a deck mid-session can keep its manifest label in OpenDeck until
the host redraws. The title is applied and persisted correctly — OpenDeck
stores it against the key instance while the canvas renders the action
definition — so the plugin re-asserts the title and nudges a redraw on layout
changes. Behaviour on genuine Elgato hardware is unverified.

Genuine Elgato hardware, Windows and macOS are unverified. Testing was
Linux + OpenDeck only.

TCI gaps found (not addressed here — no plugin-side workaround exists)

  • split_enable does not engage split. Accepted, stored and echoed, but
    TciRoutingState::splitRequested() is only ever read back — init burst, GET
    reply, diagnostic dump, internal route sequencing. Nothing drives a
    radio-side split from it. Tested with one slice and with two on the same
    band. This is the substance of TCI: SPLIT_ENABLE bidirectional command has no effect when sent by client #1686. The plugin's split action is kept so it
    starts working when this does.
  • Master mute unreachable over TCIAudioEngine::setMuted is reachable
    from shortcuts, MIDI and the GUI but has no TCI verb.
  • RADE unreachable over TCIAudioEngine::setRadeMode has no TCI verb
    and no registered controller capability.
  • volume: with an empty argument is parsed as SET-to-100%.
    TciProtocol::cmdVolume tests for an empty argument list, but "volume:"
    splits to one empty string, which is not empty, so a GET-shaped command
    drives master volume to full. The plugin sends the bare volume form to
    avoid it. Same class as TCI: spec-compliant DRIVE:<trx>; read is treated as a power SET (sets power to the TRX index) #4345.

Checklist

  • Commits are signed (docs/COMMIT-SIGNING.md)
  • No new flat-key AppSettings calls — use nested-JSON-under-one-key
    (Principle V) — n/a, no C++ changes
  • Code is clean-room — not decompiled, disassembled, or
    reverse-engineered from a proprietary binary (Principle IV)
  • All meter UI uses MeterSmoother (AGENTS.md convention) — n/a
  • Documentation updated if user-visible behavior changed
  • Security-sensitive changes reference a GHSA if applicable — n/a

Docs updated in resources/help/configuring-aethersdr-controls.md: slice
targeting and why PTT/MOX/TUNE ignore it, the dial set, the action categories,
and why there is no per-slice-index option. The committed
com.aethersdr.radio.streamDeckPlugin bundle is regenerated from source, and
the manifest Description no longer states an action count.

…ed key values

Fixes aethersdr#4513.

The bundled Elgato plugin addressed trx0 on every command, so it only ever
drove slice A, and every key carried a static manifest label — an operator
could neither point the deck at the slice they were using nor read any radio
value without looking at the AetherSDR window.

Slice targeting. A Slice Target action cycles TRX0 -> TX -> ACTIVE and applies
to mode, DSP, squelch, lock, RIT/XIT, split, slice volume, slice mute and the
VFO dial. TRX0 stays the default so an existing deck is unchanged. TX follows
tx_enable; ACTIVE follows the active_slice broadcast from aethersdr#4160.

PTT, MOX and TUNE deliberately ignore the selection and address the slice that
already holds transmit. TciProtocol::cmdTrx reassigns the TX slice before
keying, so the previous hardcoded trx:0 could move transmit to another band and
antenna on a keypress (Principle VI).

There is no per-slice-index mode by design: trx_count is sent only in the init
burst with no add/remove events, and TciProtocol::resolveSliceForTrx falls back
to the first slice for an out-of-range trx, so a numbered picker built on a
stale count would silently act on the wrong receiver. TX and ACTIVE are
broadcast on every change and cannot go stale.

Feedback on the controller. A TCI Connection Status action — first in the
action list — makes the most common "the plugin does nothing" cause visible.
Keys carrying a value show it, sourced from the radio and displaying "—" until
the radio has actually reported, rather than a plausible default that reads as
authoritative and is wrong (Principle II). State is re-requested with one-arg
GETs on connect, when a key appears, and when the target changes.

Also adds a Tune Power encoder, groups the actions into categories instead of
one flat list, and exposes the CWR, SAM, NFM and RTTY modes the server supports
but the plugin never offered.

Fixed while testing on a FLEX-6600:

- TUNE could not be switched off. cmdTune produces no notification and
  TciServer emits no tune: broadcast, so the plugin's flag never changed and
  every press re-sent "start". Now optimistic, and reconciled from trx: going
  false so stopping TUNE from the AetherSDR window is picked up (Principle II).
- The FT8 action keyed SSB, and the action is removed rather than repaired.
  TciProtocol::tciToSmartSDR maps unrecognised names to a USB default instead
  of rejecting them, and "ft8" has never been in that map — so the button took
  the slice out of a digital mode and put it on SSB. Measured on a FLEX-6600:
  from DIGU, pressing FT8 produced modulation:0,ft8 from the notification path
  and modulation:0,usb from the model in the same millisecond, with
  rx_filter_band moving 0,3000 -> 100,2800. FT8 is not a modulation but an
  operating convention on DIGU, so repairing it would only have duplicated the
  existing DIGU action; the action was dropped instead of shipping one function
  twice under two names.
- Per-slice state was cross-contaminated: the trx argument was discarded when
  parsing, so rx_nb_enable:1,true overwrote receiver 0's flag.
- The VFO dial stalled mid-spin as inbound echoes overwrote ticks accumulated
  since the last send — the command and truth paths forming the feedback loop
  Principle II prohibits. Guarded, same echo-bounce class as aethersdr#4350.
- Command records are split on the TCI terminator rather than newlines, which
  AetherSDR never sends.

Adds the first tests for any bundled plugin: test.js, 13 tests run with
`npm test`, using only node:test and node:assert so the plugin keeps its "no
build step, no npm dependencies" property. Each behavioural test spawns the
real plugin.js against fake Stream Deck and TCI sockets. streamdeck-plugins.yml
now also runs on pull requests touching plugins/ — previously it ran only on
release tags, so a syntax error would have shipped. test.js is excluded from
the packaged bundle.

plugin.js takes one testability change: the TCI port may be overridden with
AETHERSDR_TCI_PORT, defaulting to 50001, so a test run cannot collide with a
live AetherSDR or an already-running plugin instance.

No AetherSDR or TCI changes; plugin only. The committed .streamDeckPlugin
bundle is regenerated from source.

Verified on Linux + OpenDeck + Mirabox N3EN against a FLEX-6600 (firmware
4.2.20.41343) on AetherSDR 26.7.4.1, across thirteen test groups with TCI
traffic captured. Genuine Elgato hardware, Windows and macOS are unverified.
@milenjb
milenjb requested review from a team as code owners July 27, 2026 13:59
@milenjb

milenjb commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Not sure if relevant but here's the hardware test plan I followed while working on this. While it is very extensive I am certain it does not cover every possible scenario.
aethersdr-streamdeck-hardware-test-plan.md

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Genuinely strong contribution — the slice-targeting design, the decision to pin PTT/MOX/TUNE to radio.txTrx because cmdTrx reassigns the TX slice, and the reasoning for not building a trx_count-based slice picker are all correct and well argued in the comments where they'll survive. The first tests for any bundled plugin, with no new dependencies and mutation-checked, is exactly the right shape. I verified locally: all 13 tests pass, the committed .streamDeckPlugin bundle is byte-identical to source for plugin.js/manifest.json/package.json and correctly excludes test.js, and ws really is vendored so CI needs no install step.

The one thing I'd like addressed is that the "radio is authoritative" guard was applied to the display path but not the command path — keys correctly show , but pressing them still acts on the invented defaults underneath. I reproduced this against a silent fake TCI server: Tune Down emits vfo:0,0,30000; and Tune Power emits tune_drive:0,50;. The window is narrow (sub-second after connect, or after a target switch), but it's the exact invariant the PR sets out to establish.

Would like fixed before merge

  • Frequency/power/volume actions command the radio from fabricated defaults while their labels correctly show (plugin.js:723)
  • radio.volumeBeforeMute || 50 — un-muting from a genuinely-zero master volume jumps it to 50% (plugin.js:692)

Polish

  • nudgeRedrawAll() per learned key makes profile load O(N²) — I measured 14,524 host messages for a 50-key layout (plugin.js:466)
  • The new PR-triggered job inherits workflow-level contents: write (.github/workflows/streamdeck-plugins.yml:9)

Non-blocking notes

  • Removing mode-ft8 breaks existing decks that use it. Your reasoning is sound and you disclosed it clearly — flagging only so it lands in the release notes, since the failure mode for an operator is a silent "unknown action" rather than an error.
  • The TCI gaps you documented (split_enable not engaging split, volume: empty-arg parsed as SET-to-100%, master mute and RADE having no TCI verb) are worth their own issues — the volume: one in particular is the same class as #4345 and will bite the next client author too.
  • CodeGuard's two CG-PATH-001 path-traversal hits on the workflow are false positives: both are literal relative paths in a zip invocation with no externally-controlled input, and line 42 isn't even touched by this PR. Dismissed, no action needed.

🤖 aethersdr-agent · cost: $6.3984 · model: claude-opus-5

Comment on lines +723 to +724
"com.aethersdr.radio.tune-up": { keyDown: () => { const s = targetSlice(); s.frequency += TUNE_STEP_HZ; tciSend(`vfo:${targetTrx()},0,${s.frequency};`); } },
"com.aethersdr.radio.tune-down": { keyDown: () => { const s = targetSlice(); s.frequency = Math.max(VFO_FREQ_MIN, s.frequency - TUNE_STEP_HZ); tciSend(`vfo:${targetTrx()},0,${s.frequency};`); } },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The known/shown() guard covers what the key displays, but not what it commands. These two handlers compute an absolute frequency from targetSlice().frequency, which is 0 until the radio has reported — so before the first vfo: arrives, Tune Down sends the radio to 30 kHz and Tune Up to 1 kHz.

Reproduced against a fake TCI server that accepts the connection but answers nothing:

vfo:0,0,30000;      ← Tune Down
vfo:0,0,31000;      ← Tune Up
vfo:0,0,3800000;    ← Band Up landed on 80m (cursor derived from freq 0 → 160m)
tune_drive:0,50;    ← Tune Power stepped up from the fabricated 25 W baseline
rx_volume:0,60;     ← Slice Volume Up stepped from the fabricated 50

The window is narrow — requestState() fires 150 ms after connect and the init burst normally beats a keypress — but it's guaranteed to exist at every startup and again briefly after a target switch to a slice whose vfo hasn't been read. And unlike a wrong label, this one moves the radio.

The VFO dial has the same shape (getValue: () => targetSlice().frequencyclamp(0 + ticks*1000, 30000, …) → 30 kHz on the first detent), as do rf-power/tune-power/slice-volume-* and the !s.split/!s.locked toggles, which all invert a default the radio never confirmed.

Simplest fix for the frequency pair, using the helpers already here:

Suggested change
"com.aethersdr.radio.tune-up": { keyDown: () => { const s = targetSlice(); s.frequency += TUNE_STEP_HZ; tciSend(`vfo:${targetTrx()},0,${s.frequency};`); } },
"com.aethersdr.radio.tune-down": { keyDown: () => { const s = targetSlice(); s.frequency = Math.max(VFO_FREQ_MIN, s.frequency - TUNE_STEP_HZ); tciSend(`vfo:${targetTrx()},0,${s.frequency};`); } },
"com.aethersdr.radio.tune-up": { keyDown: () => { const trx = targetTrx(); if (!known.has(`vfo:${trx}`)) return; const s = slice(trx); s.frequency += TUNE_STEP_HZ; tciSend(`vfo:${trx},0,${s.frequency};`); } },
"com.aethersdr.radio.tune-down": { keyDown: () => { const trx = targetTrx(); if (!known.has(`vfo:${trx}`)) return; const s = slice(trx); s.frequency = Math.max(VFO_FREQ_MIN, s.frequency - TUNE_STEP_HZ); tciSend(`vfo:${trx},0,${s.frequency};`); } },

For the dial and the relative power/volume steppers the same idea applies — ignore the input until the corresponding known key is set, rather than stepping from an invented baseline. Worth a test alongside the existing "radio-sourced values" one, asserting that a press before any radio report emits nothing.

radio.volumeBeforeMute = radio.volume;
tciSend(volumeSetCommand(0));
} else {
tciSend(volumeSetCommand(radio.volumeBeforeMute || 50));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

|| 50 is a falsy-coalesce on a value where 0 is legitimate. If the operator already had master volume at 0 and presses Mute All twice, volumeBeforeMute is 0, so un-muting sends volume:50; and the master fader jumps to 50% — audibly, from silence, on a key labelled "UNMUTE".

volumeBeforeMute initialises to 50 and is only ever assigned from radio.volume, so the fallback isn't protecting against undefined — dropping it is sufficient:

Suggested change
tciSend(volumeSetCommand(radio.volumeBeforeMute || 50));
tciSend(volumeSetCommand(radio.volumeBeforeMute));

Comment on lines +466 to +475
function nudgeRedrawAll() {
const push = () => {
refreshKeypads(true);
for (const context of keypads.keys()) {
sdSend({ event: "setImage", context, payload: { target: 0 } });
}
};
setTimeout(push, 120);
setTimeout(push, 600);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is quadratic in key count on a profile load, and it's invoked twice per key — once by the generic learner above and again by the willAppear branch below, for the same message.

Each push() walks every registered keypad, and nudgeRedrawAll() is called once per key that appears, so N keys → N × 2 × N setImage messages plus the forced refreshKeypads inside each. Measured against the real plugin with a full manifest-derived layout:

Profile load of 50 keys -> setTitle=4524  setImage=10000  total=14524

~14.5k WebSocket messages inside ~3 s. A 32-key deck still lands around 6k. That's the same repaint-storm class the rx_smeter test pins — the meter path is now bounded, but the layout path isn't.

The per-key fan-out isn't needed: since push() already repaints every key, one coalesced round per layout event does the same job. Guarding with a single pending-timer handle (the way requestState() already coalesces its GET round) collapses this to a constant two pushes regardless of key count, and would let you drop the duplicate call in the willAppear branch too.

Comment on lines +15 to +16
test-elgato:
runs-on: ubuntu-latest

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new test-elgato job inherits the workflow-level permissions: contents: write (line 9), which exists for the release-upload job. That now grants write scope to a pull_request-triggered job whose whole purpose is to execute repo-controlled JavaScript — test.js spawns plugin.js as a child process.

For fork PRs GITHUB_TOKEN is read-only regardless, so the exposure is limited to same-repo branches; but ci.yml already documents the least-privilege convention here ("no writes, no token-bearing API calls"), and this job needs nothing:

Suggested change
test-elgato:
runs-on: ubuntu-latest
test-elgato:
runs-on: ubuntu-latest
permissions:
contents: read

Separately, minor: actions/checkout is SHA-pinned on line 18 but actions/setup-node@v4 on line 19 is a mutable tag — worth pinning to match, since they sit two lines apart.

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Cisco CodeGuard — static analysis of this PR (2 finding(s))

  • [MEDIUM] CG-PATH-001 — Potential path traversal in .github/workflows/streamdeck-plugins.yml /tmp/aetherclaude/pr-4514/.github/workflows/streamdeck-plugins.yml:36
  • [MEDIUM] CG-PATH-001 — Potential path traversal in .github/workflows/streamdeck-plugins.yml /tmp/aetherclaude/pr-4514/.github/workflows/streamdeck-plugins.yml:42

Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them.


🤖 aethersdr-agent · cost: $7.3435 · model: claude-opus-5

@aethersdr-agent

Copy link
Copy Markdown
Contributor

Antares Detector — candidate vulnerable file(s)

  • src/core/TciProtocol.cpp

Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting.


🤖 aethersdr-agent · cost: $7.3435 · model: claude-opus-5

Addresses review on aethersdr#4514.

The "radio is authoritative" guard was applied to the display path but not the
command path: keys correctly showed "—" before the radio had reported, while
pressing them still stepped from the struct's initial value and sent the
result. Against a TCI server that accepts the socket and answers nothing, a
cold start produced vfo:0,0,30000 from Tune Down, tune_drive:0,50 from Tune
Power, and six more commands built on invented baselines.

Relative gestures — those computing an absolute value from a baseline the radio
supplies — now refuse until it has been reported: tune up/down, band up/down,
the power cycles, master and slice volume, the flag toggles, and all four dials
via a `ready()` predicate on createDial. Absolute actions (band buttons, modes,
PTT/MOX/TUNE) need no baseline and stay live.

The frequency half was largely unreachable in practice —
TciProtocol::resolveSliceForTrx returns nullptr on a disconnected radio so
handleVfoRequest drops the command, and generateInitBurst sends vfo: for every
slice on connect. The power half was not: cmdTuneDrive's SET path is guarded by
`if (m_model)` rather than isConnected(), so it writes TransmitModel whatever
the radio state, and the plugin was the only thing in front of a fabricated
transmit power.

Also from review:

- Drop the `|| 50` fallback when restoring volume after Mute All. Zero is a
  legitimate level and the field can never be undefined, so the coalesce could
  only turn a genuine silence into a jump to half volume.
- Coalesce nudgeRedrawAll() and refreshKeypadsSoon() behind single pending
  handles, and drop the duplicate nudge for the same message. Both walked every
  key and were invoked per appearing key, making a profile load quadratic:
  50 keys went from 14,524 host messages to 664.
- Give the PR-triggered test job `permissions: contents: read` instead of
  inheriting the workflow's `contents: write`, and SHA-pin actions/setup-node
  to match the pinned checkout.

Two tests added: a press before any radio report commands nothing, and the same
presses do command once state is known, so the guard cannot regress into
disabling the actions. The fake radio now seeds the per-slice flags its real
counterpart sends. 15 tests pass.

Refs aethersdr#4513.
@milenjb

milenjb commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

All four addressed. Thanks — the command-path finding is a real one and I'd
missed it entirely; the silent-server reproduction is a case my hardware
testing structurally couldn't reach, since a real AetherSDR answers the init
burst before a human can press anything.

Command path now gated on known state. Split into two rules rather than
gating everything:

  • Relative gestures — anything computing an absolute value from a baseline
    the radio has to supply — refuse until it has one. That's tune up/down, band
    up/down, the RF/tune power cycles, master and slice volume steps, the flag
    toggles, and all four dials. The dial case goes through a ready() predicate
    on createDial, so it's one rule rather than four copies.
  • Absolute actions — band buttons, modes, PTT/MOX/TUNE — need no baseline and
    stay live from the first frame.

Verified against the same silent server: zero writes now, only the
requestState() reads. Two tests added — one asserting a press before any
radio report commands nothing, one asserting the same presses do command once
state is known, so the guard can't regress into simply disabling the actions.

One correction worth recording, since it changes which half matters. I couldn't
reproduce the frequency case landing on a radio: resolveSliceForTrx returns
nullptr when the radio isn't connected and handleVfoRequest bails on a null
slice, so vfo:0,0,30000 gets dropped — and when a radio is connected,
generateInitBurst has already sent vfo: for every slice. The power case is
different
: cmdTuneDrive's SET path is guarded only by if (m_model), not
isConnected(), so it writes TransmitModel::setTunePower regardless of radio
state. That's the one where the plugin was the only thing in the way, and it's
a transmit setting. Fixed either way — the invariant is worth holding
regardless of how narrow the window is — but the tune_drive/drive half is
the part that was actually reachable.

volumeBeforeMute || 50 — dropped, as suggested. You're right that the
field can't be undefined, so the coalesce could only ever corrupt a genuine
zero.

nudgeRedrawAll() fan-out — coalesced behind a single pending handle, the
way requestState() does, and the duplicate call in the willAppear branch is
gone. refreshKeypadsSoon() had the same shape and became the dominant term
once the nudge was bounded, so it's coalesced too:

Profile load of 50 keys -> setTitle=564  setImage=100  total=664   (was 14,524)

For context on why the nudge exists at all, since it isn't in the diff: a key
added mid-session keeps rendering its manifest label on the device as well as
in the editor. OpenDeck does receive and persist our setTitle — the stored
profile holds our text against the key instance while the canvas renders the
action definition — so what's missing is a redraw trigger, not the data. It's
tied to layout events only, never a timer.

Workflowpermissions: contents: read on the test job, and setup-node
pinned to 49933ea5288caeca8642d1e84afbd3f7d6820020 to match the SHA-pinned
checkout.

FT8 in the release notes — agreed, and I'd rather the team write that
against whatever is approved than guess at it here.

15 tests pass. Bundle rebuilt and still excludes test.js.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stream Deck plugin: make actions slice-aware and show radio state on the keys

1 participant

Morty Proxy This is a proxified and sanitized view of the page, visit original site.