feat(radio): answer the RTL-SDR card through the radio store

SdrCard calls LocalSdrControls through radio store actions instead of
DOM listeners, and renders its gain options from the receiver's own
R820T gain steps with page-language labels. The display and radio
stores share one owner list (createOwners): the newest connected owner
with an action answers it.
This commit is contained in:
Sameh Khamis committed 2026-11-01 10:52:50 -08:00
1 parent e5fa31d01e
commit 61d11ce369
12 files changed
+299 -185

No files matched your search

+3 -1
View File
@@ -58,7 +58,9 @@
list renders. Street Level's fit and panorama filters and Recent Imagery's list renders. Street Level's fit and panorama filters and Recent Imagery's
mode are `UiSegmented` too: the Street Level store's owner answers mode are `UiSegmented` too: the Street Level store's owner answers
`setPano` and `setRenderMode`, and `RecentImageryReadout` takes `setPano` and `setRenderMode`, and `RecentImageryReadout` takes
`onSetMode`. `syncScopeControls` (`./ui/display`) and `onSetMode`. The local RTL-SDR card's controls call `LocalSdrControls`
through the radio store's new `connect`/`act`, and the card labels its gain
options itself. `syncScopeControls` (`./ui/display`) and
`shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display `shouldHideCollapsedRightPanels` (`./ui/layout`) are deprecated: the Display
view and the rail now own what they did. `./ui/input` imports Vue view and the rail now own what they did. `./ui/input` imports Vue
(samehkhamis). (samehkhamis).
+2 -1
View File
@@ -147,7 +147,8 @@ a feature supplying only its accent.
Context and Radio (in progress: `RadioPanel` renders the directory row, Context and Radio (in progress: `RadioPanel` renders the directory row,
station card, transport, volume and playback line from the `gev-radio` station card, transport, volume and playback line from the `gev-radio`
store that RadioControls publishes to, and `SdrCard` the local RTL-SDR store that RadioControls publishes to, and `SdrCard` the local RTL-SDR
card that LocalSdrControls publishes beside it; fields a person edits take card that LocalSdrControls publishes beside it and answers through the
store's `connect`/`act` (the shared `createOwners()`); fields a person edits take
their value through `v-field-value`, which never writes a focused field; their value through `v-field-value`, which never writes a focused field;
`RadioTuner` renders the directory tuner, where RadioControls keeps the `RadioTuner` renders the directory tuner, where RadioControls keeps the
drag and its slot and publishes the band, readouts, slider and needle, drag and its slot and publishes the band, readouts, slider and needle,
+3
View File
@@ -913,6 +913,7 @@
"src/sources/adsbRecords.test.mjs", "src/sources/adsbRecords.test.mjs",
"src/layers/localAdsb/index.test.mjs", "src/layers/localAdsb/index.test.mjs",
"src/ui/localSdrControls.test.mjs", "src/ui/localSdrControls.test.mjs",
"src/ui/sdrCardActions.test.mjs",
"src/ui/localSdrMarkup.test.mjs", "src/ui/localSdrMarkup.test.mjs",
"src/ui/localSdrPresentation.test.mjs", "src/ui/localSdrPresentation.test.mjs",
"src/data/localReceiversProxy.test.mjs", "src/data/localReceiversProxy.test.mjs",
@@ -1131,6 +1132,8 @@
"build/vueLoader.js", "build/vueLoader.js",
"build/registerVue.js", "build/registerVue.js",
"src/ui/panelNavigation.test.mjs", "src/ui/panelNavigation.test.mjs",
"src/ui/stores/owners.js",
"src/ui/stores/owners.test.mjs",
"src/ui/stores/panels.test.mjs", "src/ui/stores/panels.test.mjs",
"src/ui/stores/status.js", "src/ui/stores/status.js",
"src/ui/statusChipsView.test.mjs", "src/ui/statusChipsView.test.mjs",
+10 -1
View File
@@ -88,6 +88,10 @@
"src/ui/views/panels/DisplayPanel.vue", "src/ui/views/panels/DisplayPanel.vue",
"src/ui/views/panels/GeoImport.vue", "src/ui/views/panels/GeoImport.vue",
"src/ui/stores/display.js", "src/ui/stores/display.js",
"src/ui/localSdrPresentation.js",
"src/ui/unitLabels.js",
"src/layers/localAdsb/feedNames.js",
"src/ui/stores/owners.js",
"src/ui/rail/rangeValue.js", "src/ui/rail/rangeValue.js",
"src/ui/rail/classToggle.js", "src/ui/rail/classToggle.js",
"src/ui/rail/focusSafeChildren.js", "src/ui/rail/focusSafeChildren.js",
@@ -128,7 +132,8 @@
"src/ui/kit/UiPanelToggle.vue", "src/ui/kit/UiPanelToggle.vue",
"src/ui/stores/panelChrome.js", "src/ui/stores/panelChrome.js",
"src/ui/rail/PanelCollapseButton.vue", "src/ui/rail/PanelCollapseButton.vue",
"src/ui/rail/panelState.js" "src/ui/rail/panelState.js",
"src/sdr/gain.js"
], ],
"external": ["vue", "pinia"] "external": ["vue", "pinia"]
}, },
@@ -696,6 +701,7 @@
"src/ui/radioPresentation.js", "src/ui/radioPresentation.js",
"src/ui/vueRuntime.js", "src/ui/vueRuntime.js",
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/owners.js",
"src/ui/radioTunerModel.js", "src/ui/radioTunerModel.js",
"src/i18n/index.js", "src/i18n/index.js",
"src/i18n/locale.js", "src/i18n/locale.js",
@@ -1046,6 +1052,7 @@
"src/ui/stores/voice.js", "src/ui/stores/voice.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/display.js", "src/ui/stores/display.js",
"src/ui/stores/owners.js",
"src/ui/stores/status.js", "src/ui/stores/status.js",
"src/ui/toast.js", "src/ui/toast.js",
"src/ui/stores/missionReplay.js", "src/ui/stores/missionReplay.js",
@@ -3042,6 +3049,7 @@
"src/ui/stores/voice.js", "src/ui/stores/voice.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/display.js", "src/ui/stores/display.js",
"src/ui/stores/owners.js",
"src/ui/stores/imagerySplit.js", "src/ui/stores/imagerySplit.js",
"src/ui/stores/status.js", "src/ui/stores/status.js",
"src/ui/toast.js", "src/ui/toast.js",
@@ -3818,6 +3826,7 @@
"src/ui/visualPresets.js", "src/ui/visualPresets.js",
"src/ui/visualSettings.js", "src/ui/visualSettings.js",
"src/ui/stores/display.js", "src/ui/stores/display.js",
"src/ui/stores/owners.js",
"src/ui/vueRuntime.js", "src/ui/vueRuntime.js",
"src/i18n/marker.js", "src/i18n/marker.js",
"src/i18n/index.js", "src/i18n/index.js",
+36 -89
View File
@@ -4,40 +4,16 @@ import {
localSdrCardView, localSdrCardView,
localSdrFeedLine, localSdrFeedLine,
localSdrFmAudioActive, localSdrFmAudioActive,
localSdrGainOptionLabel,
} from './localSdrPresentation.js'; } from './localSdrPresentation.js';
import { useRadioStore } from './stores/radio.js'; import { useRadioStore } from './stores/radio.js';
import { applicationPinia } from './vueRuntime.js'; import { applicationPinia } from './vueRuntime.js';
const ELEMENT_IDS = Object.freeze({
card: 'sdr-radio-card',
connectionState: 'sdr-connection-state',
connect: 'sdr-connect-btn',
locate: 'sdr-locate-btn',
changeDevice: 'sdr-change-device-btn',
modeFm: 'sdr-mode-fm-btn',
modeAdsb: 'sdr-mode-adsb-btn',
gain: 'sdr-gain-select',
stats: 'sdr-adsb-stats',
statRate: 'sdr-stat-rate',
statHeard: 'sdr-stat-heard',
statPositioned: 'sdr-stat-positioned',
statIq: 'sdr-stat-iq',
frequency: 'sdr-frequency-input',
tune: 'sdr-tune-btn',
seekBack: 'sdr-seek-back-btn',
seekForward: 'sdr-seek-forward-btn',
volume: 'sdr-volume',
volumeValue: 'sdr-volume-value',
status: 'sdr-status',
feedStatus: 'sdr-feed-status',
});
/** /**
* Local RTL-SDR card in the Radio panel. * Local RTL-SDR card in the Radio panel.
* *
* Owns the card's listeners and its receiver/radio subscriptions; the Radio * Answers the card's controls and owns its receiver/radio subscriptions; the
* view renders the card from what this publishes (`gev-radio` store). The * Radio view renders the card from what this publishes (`gev-radio` store)
* and sends its controls here through the store's act(). The
* Radio panel is one listening surface: starting local FM stops internet radio, and * Radio panel is one listening surface: starting local FM stops internet radio, and
* starting internet radio stops local FM. * starting internet radio stops local FM.
*/ */
@@ -67,13 +43,6 @@ export class LocalSdrControls {
this.feeds = feeds; this.feeds = feeds;
this.radio = radio; this.radio = radio;
this.actions = actions; this.actions = actions;
this.elements = Object.fromEntries(
Object.entries(ELEMENT_IDS).map(([key, id]) => [
key,
doc?.getElementById?.(id) || null,
]),
);
this._listeners = new AbortController();
this._state = receiver.getState(); this._state = receiver.getState();
this._radioState = null; this._radioState = null;
this._fmAudioActive = localSdrFmAudioActive(this._state); this._fmAudioActive = localSdrFmAudioActive(this._state);
@@ -81,9 +50,9 @@ export class LocalSdrControls {
this._renderFrame = null; this._renderFrame = null;
this._destroyed = false; this._destroyed = false;
this._view = null; this._view = null;
if (!this.elements.card) return; // A page without the card (the Radio view omitted) has no controls.
this._labelGainOptions(); if (!doc?.getElementById?.('sdr-radio-card')) return;
this._bind(); this._storeToken = store.connect(this._actions());
this._receiverUnsubscribe = receiver.subscribe((state) => this._receiverUnsubscribe = receiver.subscribe((state) =>
this._onReceiverState(state), this._onReceiverState(state),
); );
@@ -101,20 +70,6 @@ export class LocalSdrControls {
return Boolean(this._state?.connected); return Boolean(this._state?.connected);
} }
/** Numeric gain options read in the page language; values stay numeric. */
_labelGainOptions() {
for (const option of this.elements.gain?.options || []) {
if (option.value !== 'auto')
option.textContent = localSdrGainOptionLabel(option.value);
}
}
_listen(element, type, handler) {
element?.addEventListener(type, handler, {
signal: this._listeners.signal,
});
}
_stopInternetRadio() { _stopInternetRadio() {
if (internetRadioAudioActive(this._radioState)) if (internetRadioAudioActive(this._radioState))
this.radio?.stopPlayback?.({ origin: 'user' }); this.radio?.stopPlayback?.({ origin: 'user' });
@@ -126,9 +81,10 @@ export class LocalSdrControls {
await this.actions.setLocalAdsbEnabled?.(true); await this.actions.setLocalAdsbEnabled?.(true);
} }
_bind() { /** What the card's controls ask for, by the names the view acts with. */
const el = this.elements; _actions() {
this._listen(el.connect, 'click', async () => { return {
toggleSdrConnection: async () => {
const before = this.receiver.getState(); const before = this.receiver.getState();
if (before.connected) { if (before.connected) {
await this.receiver.stop(); await this.receiver.stop();
@@ -137,54 +93,44 @@ export class LocalSdrControls {
if (before.mode === 'fm') this._stopInternetRadio(); if (before.mode === 'fm') this._stopInternetRadio();
if (await this.receiver.connect(before.mode)) if (await this.receiver.connect(before.mode))
await this._enableLayerWhenReceiving(); await this._enableLayerWhenReceiving();
}); },
this._listen(el.locate, 'click', () => { locateSdr: () => {
void this.receiver.requestReceiverLocation(); void this.receiver.requestReceiverLocation();
}); },
this._listen(el.changeDevice, 'click', async () => { changeSdrDevice: async () => {
if (this.receiver.getState().mode === 'fm') this._stopInternetRadio(); if (this.receiver.getState().mode === 'fm') this._stopInternetRadio();
if (await this.receiver.changeDevice()) if (await this.receiver.changeDevice())
await this._enableLayerWhenReceiving(); await this._enableLayerWhenReceiving();
}); },
this._listen(el.modeFm, 'click', async () => { setSdrMode: async (mode) => {
if (mode === 'fm') {
this._stopInternetRadio(); this._stopInternetRadio();
if (this.actions.isLocalAdsbEnabled?.()) if (this.actions.isLocalAdsbEnabled?.())
await this.actions.setLocalAdsbEnabled?.(false); await this.actions.setLocalAdsbEnabled?.(false);
await this.receiver.setMode('fm'); await this.receiver.setMode('fm');
}); } else if (await this.receiver.setMode('adsb'))
this._listen(el.modeAdsb, 'click', async () => {
if (await this.receiver.setMode('adsb'))
await this._enableLayerWhenReceiving(); await this._enableLayerWhenReceiving();
}); },
this._listen(el.gain, 'change', () => { setSdrGain: (value) => {
void this.receiver.setGain(el.gain.value); void this.receiver.setGain(value);
}); },
const tune = () => { tuneSdr: (value) => {
const mhz = Number(el.frequency?.value); const mhz = Number(value);
if (!Number.isFinite(mhz)) return; if (!Number.isFinite(mhz)) return;
this._stopInternetRadio(); this._stopInternetRadio();
void this.receiver.tuneFm(mhz * 1_000_000); void this.receiver.tuneFm(mhz * 1_000_000);
}; },
this._listen(el.tune, 'click', tune); seekSdr: (direction) => {
this._listen(el.frequency, 'keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
tune();
});
for (const [button, direction] of [
[el.seekBack, -1],
[el.seekForward, 1],
]) {
this._listen(button, 'click', () => {
this._stopInternetRadio(); this._stopInternetRadio();
void this.receiver.seekFm(direction); void this.receiver.seekFm(direction);
}); },
} setSdrVolume: (percent) => {
this._listen(el.volume, 'input', () => { const value = Number(percent);
const value = Number(el.volume.value);
this.receiver.setVolume(value / 100); this.receiver.setVolume(value / 100);
if (this._view) this._publish({ ...this._view, volumeText: `${value}%` }); if (this._view)
}); this._publish({ ...this._view, volumeText: `${value}%` });
},
};
} }
_onReceiverState(state) { _onReceiverState(state) {
@@ -246,10 +192,11 @@ export class LocalSdrControls {
} }
} }
/** Release listeners and subscriptions; the receiver session is not closed. */ /** Release the controls and subscriptions; the receiver session is not closed. */
destroy() { destroy() {
this._destroyed = true; this._destroyed = true;
this._listeners.abort(); if (this._storeToken) this.store.detach(this._storeToken);
this._storeToken = null;
this._receiverUnsubscribe?.(); this._receiverUnsubscribe?.();
this._radioUnsubscribe?.(); this._radioUnsubscribe?.();
this._feedsUnsubscribe?.(); this._feedsUnsubscribe?.();
+41 -11
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { LocalSdrControls } from './localSdrControls.js'; import { LocalSdrControls } from './localSdrControls.js';
import { createOwners } from './stores/owners.js';
function stubDocument() { function stubDocument() {
const elements = new Map(); const elements = new Map();
@@ -9,7 +10,6 @@ function stubDocument() {
activeElement: null, activeElement: null,
getElementById(id) { getElementById(id) {
if (!elements.has(id)) { if (!elements.has(id)) {
const listeners = new Map();
elements.set(id, { elements.set(id, {
id, id,
value: '', value: '',
@@ -20,10 +20,6 @@ function stubDocument() {
classList: { toggle() {} }, classList: { toggle() {} },
setAttribute() {}, setAttribute() {},
closest: () => null, closest: () => null,
addEventListener(type, handler) {
listeners.set(type, handler);
},
fire: (type, event = {}) => listeners.get(type)?.(event),
}); });
} }
return elements.get(id); return elements.get(id);
@@ -79,6 +75,12 @@ function stubReceiver() {
}; };
} }
// The radio store as the card's owner sees it: published views and the
// card's controls, which act() calls.
function stubStore(onPublish = () => {}) {
return { ...createOwners(), publishSdr: onPublish };
}
function stubRadio() { function stubRadio() {
const listeners = new Set(); const listeners = new Set();
const calls = []; const calls = [];
@@ -141,28 +143,56 @@ test('card controls drive gain, FM mode and the Local ADS-B layer', async (t) =>
const doc = stubDocument(); const doc = stubDocument();
const receiver = stubReceiver(); const receiver = stubReceiver();
const layerCalls = []; const layerCalls = [];
const store = stubStore();
const controls = new LocalSdrControls({ const controls = new LocalSdrControls({
document: doc, document: doc,
receiver, receiver,
store,
actions: { actions: {
isLocalAdsbEnabled: () => true, isLocalAdsbEnabled: () => true,
setLocalAdsbEnabled: async (enabled) => layerCalls.push(enabled), setLocalAdsbEnabled: async (enabled) => layerCalls.push(enabled),
}, },
}); });
t.after(() => controls.destroy()); t.after(() => controls.destroy());
const gain = doc.getElementById('sdr-gain-select'); store.act('setSdrGain', '20.7');
gain.value = '20.7';
gain.fire('change');
assert.deepEqual(receiver.calls.at(-1), ['setGain', '20.7']); assert.deepEqual(receiver.calls.at(-1), ['setGain', '20.7']);
receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' }); receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' });
await doc.getElementById('sdr-mode-fm-btn').fire('click'); store.act('setSdrMode', 'fm');
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(layerCalls, [false]); assert.deepEqual(layerCalls, [false]);
assert.deepEqual(receiver.calls.at(-1), ['setMode', 'fm']); assert.deepEqual(receiver.calls.at(-1), ['setMode', 'fm']);
await doc.getElementById('sdr-mode-adsb-btn').fire('click'); store.act('setSdrMode', 'adsb');
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(layerCalls, [false, true]); assert.deepEqual(layerCalls, [false, true]);
assert.equal(controls.isActive(), true); assert.equal(controls.isActive(), true);
store.act('tuneSdr', '101.1');
assert.deepEqual(receiver.calls.at(-1), ['tuneFm', 101_100_000]);
store.act('tuneSdr', 'abc');
assert.deepEqual(
receiver.calls.at(-1),
['tuneFm', 101_100_000],
'not a number',
);
controls.destroy();
store.act('setSdrGain', '0.0');
assert.deepEqual(receiver.calls.at(-1), ['tuneFm', 101_100_000], 'destroyed');
});
test('a page without the card connects nothing', (t) => {
const store = stubStore();
const receiver = stubReceiver();
const controls = new LocalSdrControls({
document: { getElementById: () => null },
receiver,
store,
});
t.after(() => controls.destroy());
store.act('setSdrGain', '0.0');
assert.equal(receiver.calls.length, 0);
}); });
test('the card shows one read-only decoder-feed line after a single probe', async (t) => { test('the card shows one read-only decoder-feed line after a single probe', async (t) => {
@@ -189,7 +219,7 @@ test('the card shows one read-only decoder-feed line after a single probe', asyn
document: doc, document: doc,
receiver: stubReceiver(), receiver: stubReceiver(),
feeds, feeds,
store: { publishSdr: (view) => (shown = view) }, store: stubStore((view) => (shown = view)),
}); });
t.after(() => controls.destroy()); t.after(() => controls.destroy());
assert.equal(probes, 1); assert.equal(probes, 1);
+97
View File
@@ -0,0 +1,97 @@
// src/ui/sdrCardActions.test.mjs — the local RTL-SDR card's controls call
// LocalSdrControls through the radio store, and its gain options read in the
// page language with numeric values.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { localSdrGainOptionLabel } from './localSdrPresentation.js';
import { useRadioStore } from './stores/radio.js';
import SdrCard from './views/panels/SdrCard.vue';
function mount(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useRadioStore(pinia);
const calls = [];
store.connect(
new Proxy(
{},
{
get:
(_, name) =>
(...args) =>
calls.push([name, ...args]),
},
),
);
const app = createApp(SdrCard).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
return calls;
}
const $ = (id) => document.getElementById(id);
const fire = (el, type, init) =>
el.dispatchEvent(
type === 'keydown'
? new KeyboardEvent(type, { bubbles: true, cancelable: true, ...init })
: new Event(type, { bubbles: true }),
);
test('the card’s buttons, gain, tuning and volume call LocalSdrControls', (t) => {
const calls = mount(t);
for (const id of [
'sdr-connect-btn',
'sdr-locate-btn',
'sdr-change-device-btn',
'sdr-mode-fm-btn',
'sdr-mode-adsb-btn',
])
$(id).click();
$('sdr-gain-select').value = '20.7';
fire($('sdr-gain-select'), 'change');
$('sdr-frequency-input').value = '101.1';
$('sdr-tune-btn').click();
$('sdr-frequency-input').value = '95.3';
const enter = fire($('sdr-frequency-input'), 'keydown', { key: 'Enter' });
fire($('sdr-frequency-input'), 'keydown', { key: 'a' });
$('sdr-volume').value = '40';
fire($('sdr-volume'), 'input');
assert.equal(enter, false, 'Enter is taken by the tune');
assert.deepEqual(calls, [
['toggleSdrConnection'],
['locateSdr'],
['changeSdrDevice'],
['setSdrMode', 'fm'],
['setSdrMode', 'adsb'],
['setSdrGain', '20.7'],
['tuneSdr', '101.1'],
['tuneSdr', '95.3'],
['setSdrVolume', '40'],
]);
});
test('the seek buttons step down and up', (t) => {
const calls = mount(t);
for (const id of ['sdr-seek-back-btn', 'sdr-seek-forward-btn']) {
$(id).disabled = false;
$(id).click();
}
assert.deepEqual(calls, [
['seekSdr', -1],
['seekSdr', 1],
]);
});
test('gain options keep numeric values and read as decibels', (t) => {
mount(t);
const options = [...$('sdr-gain-select').options];
assert.equal(options.length, 30);
assert.equal(options[0].value, 'auto');
assert.equal(options[2].value, '0.9');
assert.equal(options[2].textContent, localSdrGainOptionLabel('0.9'));
assert.match(options[2].textContent, /^0\.9\sdB$/u);
assert.equal(options[2].getAttribute('translate'), 'no');
assert.equal(options.at(-1).value, '49.6');
});
+4 -38
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue'; import { markRaw, shallowRef } from 'vue';
import { createOwners } from './owners.js';
/** /**
* The Display panel as its view renders it. VisualSettings presents its * The Display panel as its view renders it. VisualSettings presents its
@@ -67,44 +68,9 @@ export const useDisplayStore = defineStore('gev-display', () => {
mapSourceControls.value = controls && markRaw(controls); mapSourceControls.value = controls && markRaw(controls);
} }
/** // DisplayBindings answers the Display controls and the dock's style
* The owners answering the panel's controls, oldest first: DisplayBindings // buttons, the draw tool and the file import their own.
* the Display controls and the dock's style buttons, the draw tool and the const { connect, detach, act } = createOwners();
* file import their own.
* @type {Array<{actions: object}>}
*/
const owners = [];
/**
* Answer the actions named in `actions`; for those names this owner
* replaces any earlier one.
* @param {Record<string, Function>} actions
* @returns {object} Token for detach().
*/
function connect(actions) {
const token = { actions };
owners.push(token);
return token;
}
/** Stop answering for the owner holding `token`; the others stay. */
function detach(token) {
const index = owners.indexOf(token);
if (index >= 0) owners.splice(index, 1);
}
/**
* A control's change: call the newest connected owner's action of that
* name, if any.
* @param {string} name
* @param {...unknown} args
*/
function act(name, ...args) {
for (let index = owners.length - 1; index >= 0; index -= 1) {
const action = owners[index].actions[name];
if (action) return void action(...args);
}
}
/** The phone shell's tab bar; null until the shell first presents it. */ /** The phone shell's tab bar; null until the shell first presents it. */
const phoneShell = shallowRef(null); const phoneShell = shallowRef(null);
+43
View File
@@ -0,0 +1,43 @@
/**
* The owners answering a store's controls. A view calls `act(name, ...args)`;
* the newest connected owner with an action of that name answers it, so an
* owner that connects again replaces itself, and owners of different actions
* sit side by side.
* @returns {{connect: Function, detach: Function, act: Function}}
*/
export function createOwners() {
/** @type {Array<{actions: object}>} Oldest first. */
const owners = [];
/**
* Answer the actions named in `actions`.
* @param {Record<string, Function>} actions
* @returns {object} Token for detach().
*/
function connect(actions) {
const token = { actions };
owners.push(token);
return token;
}
/** Stop answering for the owner holding `token`; the others stay. */
function detach(token) {
const index = owners.indexOf(token);
if (index >= 0) owners.splice(index, 1);
}
/**
* A control's change: call the newest connected owner's action of that
* name, if any.
* @param {string} name
* @param {...unknown} args
*/
function act(name, ...args) {
for (let index = owners.length - 1; index >= 0; index -= 1) {
const action = owners[index].actions[name];
if (action) return void action(...args);
}
}
return { connect, detach, act };
}
+28
View File
@@ -0,0 +1,28 @@
// src/ui/stores/owners.test.mjs — the newest connected owner with an action
// answers it; detaching hands the action back to the one before.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createOwners } from './owners.js';
test('the newest owner with an action answers it, and detach hands it back', () => {
const { connect, detach, act } = createOwners();
const calls = [];
const first = connect({
play: (n) => calls.push(['first.play', n]),
stop: () => calls.push(['first.stop']),
});
const second = connect({ play: (n) => calls.push(['second.play', n]) });
act('play', 1);
act('stop');
act('missing');
detach(second);
act('play', 2);
detach(second);
detach(first);
act('play', 3);
assert.deepEqual(calls, [
['second.play', 1],
['first.stop'],
['first.play', 2],
]);
});
+8 -1
View File
@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue'; import { markRaw, shallowRef } from 'vue';
import { createOwners } from './owners.js';
/** /**
* The Radio panel as its view renders it. RadioControls presents the * The Radio panel as its view renders it. RadioControls presents the
@@ -7,7 +8,7 @@ import { markRaw, shallowRef } from 'vue';
* Cockpit's compact radio controls (`renderRadioState()`), which Cockpit * Cockpit's compact radio controls (`renderRadioState()`), which Cockpit
* utility popover is open, and the directory tuner, and publishes them here; * utility popover is open, and the directory tuner, and publishes them here;
* it keeps its listeners. LocalSdrControls publishes the local RTL-SDR card * it keeps its listeners. LocalSdrControls publishes the local RTL-SDR card
* beside it. * beside it, and the card's controls reach it through act().
*/ */
export const useRadioStore = defineStore('gev-radio', () => { export const useRadioStore = defineStore('gev-radio', () => {
/** The presented panel; null until the controls first render. */ /** The presented panel; null until the controls first render. */
@@ -42,6 +43,9 @@ export const useRadioStore = defineStore('gev-radio', () => {
tuner.value = markRaw(next); tuner.value = markRaw(next);
} }
// LocalSdrControls answers the RTL-SDR card's controls.
const { connect, detach, act } = createOwners();
return { return {
view, view,
publish, publish,
@@ -51,5 +55,8 @@ export const useRadioStore = defineStore('gev-radio', () => {
publishUtility, publishUtility,
tuner, tuner,
publishTuner, publishTuner,
connect,
detach,
act,
}; };
}); });
+24 -43
View File
@@ -1,14 +1,23 @@
<script setup> <script setup>
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import { t } from '../../../i18n/index.js'; import { t } from '../../../i18n/index.js';
import { R820T_GAIN_STEPS_DB } from '../../../sdr/gain.js';
import { localSdrGainOptionLabel } from '../../localSdrPresentation.js';
import { vClassToggle } from '../../rail/classToggle.js'; import { vClassToggle } from '../../rail/classToggle.js';
import { vFieldValue } from '../../rail/fieldValue.js'; import { vFieldValue } from '../../rail/fieldValue.js';
import { useRadioStore } from '../../stores/radio.js'; import { useRadioStore } from '../../stores/radio.js';
// The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and // The local RTL-SDR card in the Radio panel. LocalSdrControls presents it and
// publishes it; until it does, the card shows its initial markup. The // publishes it; until it does, the card shows its initial markup. Its
// numeric gain options are labelled once by the controls. // controls call LocalSdrControls through the store. The numeric gain
const { sdr } = storeToRefs(useRadioStore()); // options read in the page language; their values stay numeric.
const radio = useRadioStore();
const { sdr } = storeToRefs(radio);
const { act } = radio;
const frequency = ref(null);
const GAINS = R820T_GAIN_STEPS_DB.map((gain) => gain.toFixed(1));
const tune = () => act('tuneSdr', frequency.value.value);
</script> </script>
<template> <template>
@@ -19,47 +28,19 @@ const { sdr } = storeToRefs(useRadioStore());
</div> </div>
<p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p> <p data-i18n>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
<div class="sdr-radio-actions"> <div class="sdr-radio-actions">
<button id="sdr-connect-btn" class="panel-layer-toggle" :class="{ active: sdr?.connectPressed }" type="button" :aria-pressed="String(sdr?.connectPressed ?? false)" :disabled="sdr?.connectDisabled">{{ sdr ? sdr.connectLabel : t('CONNECT') }}</button> <button id="sdr-connect-btn" @click="act('toggleSdrConnection')" class="panel-layer-toggle" :class="{ active: sdr?.connectPressed }" type="button" :aria-pressed="String(sdr?.connectPressed ?? false)" :disabled="sdr?.connectDisabled">{{ sdr ? sdr.connectLabel : t('CONNECT') }}</button>
<button id="sdr-locate-btn" type="button" v-class-toggle="{ active: sdr?.locateActive }" title="Use your location for faster ADS-B position decoding" :disabled="sdr?.locateDisabled" data-i18n-title>{{ sdr ? sdr.locateLabel : t('LOCATE') }}</button> <button id="sdr-locate-btn" type="button" @click="act('locateSdr')" v-class-toggle="{ active: sdr?.locateActive }" title="Use your location for faster ADS-B position decoding" :disabled="sdr?.locateDisabled" data-i18n-title>{{ sdr ? sdr.locateLabel : t('LOCATE') }}</button>
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel" :disabled="sdr?.changeDeviceDisabled" data-i18n-title data-i18n>CHANGE DEVICE</button> <button id="sdr-change-device-btn" type="button" @click="act('changeSdrDevice')" title="Choose a different RTL-SDR or receiver channel" :disabled="sdr?.changeDeviceDisabled" data-i18n-title data-i18n>CHANGE DEVICE</button>
</div> </div>
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label> <div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode" data-i18n-aria-label>
<button id="sdr-mode-fm-btn" class="active" v-class-toggle="sdr ? { active: sdr.fmActive } : null" type="button" :aria-pressed="String(sdr ? sdr.fmActive : true)" :disabled="sdr?.modeDisabled" translate="no">FM</button> <button id="sdr-mode-fm-btn" @click="act('setSdrMode', 'fm')" class="active" v-class-toggle="sdr ? { active: sdr.fmActive } : null" type="button" :aria-pressed="String(sdr ? sdr.fmActive : true)" :disabled="sdr?.modeDisabled" translate="no">FM</button>
<button id="sdr-mode-adsb-btn" type="button" v-class-toggle="{ active: sdr?.adsbActive }" :aria-pressed="String(sdr?.adsbActive ?? false)" :disabled="sdr?.modeDisabled" translate="no">ADS-B · 1090</button> <button id="sdr-mode-adsb-btn" type="button" @click="act('setSdrMode', 'adsb')" v-class-toggle="{ active: sdr?.adsbActive }" :aria-pressed="String(sdr?.adsbActive ?? false)" :disabled="sdr?.modeDisabled" translate="no">ADS-B · 1090</button>
</div> </div>
<div class="sdr-gain-row"> <div class="sdr-gain-row">
<label for="sdr-gain-select" data-i18n>GAIN</label> <label for="sdr-gain-select" data-i18n>GAIN</label>
<select class="gev-field" id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode" :disabled="sdr?.gainDisabled" v-field-value="sdr ? sdr.gain : 'auto'" data-i18n-aria-label> <select class="gev-field" id="sdr-gain-select" @change="act('setSdrGain', $event.target.value)" aria-label="RTL-SDR tuner gain for the current mode" :disabled="sdr?.gainDisabled" v-field-value="sdr ? sdr.gain : 'auto'" data-i18n-aria-label>
<option value="auto" data-i18n>AUTO</option> <option value="auto" data-i18n>AUTO</option>
<option value="0.0" translate="no">0.0 dB</option> <option v-for="gain in GAINS" :key="gain" :value="gain" translate="no">{{ localSdrGainOptionLabel(gain) }}</option>
<option value="0.9" translate="no">0.9 dB</option>
<option value="1.4" translate="no">1.4 dB</option>
<option value="2.7" translate="no">2.7 dB</option>
<option value="3.7" translate="no">3.7 dB</option>
<option value="7.7" translate="no">7.7 dB</option>
<option value="8.7" translate="no">8.7 dB</option>
<option value="12.5" translate="no">12.5 dB</option>
<option value="14.4" translate="no">14.4 dB</option>
<option value="15.7" translate="no">15.7 dB</option>
<option value="16.6" translate="no">16.6 dB</option>
<option value="19.7" translate="no">19.7 dB</option>
<option value="20.7" translate="no">20.7 dB</option>
<option value="22.9" translate="no">22.9 dB</option>
<option value="25.4" translate="no">25.4 dB</option>
<option value="28.0" translate="no">28.0 dB</option>
<option value="29.7" translate="no">29.7 dB</option>
<option value="32.8" translate="no">32.8 dB</option>
<option value="33.8" translate="no">33.8 dB</option>
<option value="36.4" translate="no">36.4 dB</option>
<option value="37.2" translate="no">37.2 dB</option>
<option value="38.6" translate="no">38.6 dB</option>
<option value="40.2" translate="no">40.2 dB</option>
<option value="42.1" translate="no">42.1 dB</option>
<option value="43.4" translate="no">43.4 dB</option>
<option value="43.9" translate="no">43.9 dB</option>
<option value="44.5" translate="no">44.5 dB</option>
<option value="48.0" translate="no">48.0 dB</option>
<option value="49.6" translate="no">49.6 dB</option>
</select> </select>
</div> </div>
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" :hidden="sdr ? sdr.statsHidden : true"> <div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" :hidden="sdr ? sdr.statsHidden : true">
@@ -70,16 +51,16 @@ const { sdr } = storeToRefs(useRadioStore());
</div> </div>
<div class="sdr-frequency-row"> <div class="sdr-frequency-row">
<label for="sdr-frequency-input" data-i18n>FM MHZ</label> <label for="sdr-frequency-input" data-i18n>FM MHZ</label>
<input class="gev-field" id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" :disabled="sdr?.frequencyDisabled" v-field-value="sdr ? sdr.frequency : '98.5'" /> <input ref="frequency" class="gev-field" id="sdr-frequency-input" @keydown.enter.prevent="tune" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" :disabled="sdr?.frequencyDisabled" v-field-value="sdr ? sdr.frequency : '98.5'" />
<button id="sdr-tune-btn" type="button" :disabled="sdr?.tuneDisabled" data-i18n>TUNE</button> <button id="sdr-tune-btn" type="button" @click="tune" :disabled="sdr?.tuneDisabled" data-i18n>TUNE</button>
</div> </div>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label> <div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations" data-i18n-aria-label>
<button id="sdr-seek-back-btn" type="button" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>◀ SEEK</button> <button id="sdr-seek-back-btn" type="button" @click="act('seekSdr', -1)" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>◀ SEEK</button>
<button id="sdr-seek-forward-btn" type="button" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>SEEK ▶</button> <button id="sdr-seek-forward-btn" type="button" @click="act('seekSdr', 1)" :disabled="sdr ? sdr.seekDisabled : true" data-i18n>SEEK ▶</button>
</div> </div>
<label class="radio-volume-row" for="sdr-volume"> <label class="radio-volume-row" for="sdr-volume">
<span data-i18n>SDR VOL</span> <span data-i18n>SDR VOL</span>
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" :disabled="sdr?.volumeDisabled" v-field-value="sdr ? sdr.volume : 80" data-i18n-aria-label /> <input id="sdr-volume" @input="act('setSdrVolume', $event.target.value)" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" :disabled="sdr?.volumeDisabled" v-field-value="sdr ? sdr.volume : 80" data-i18n-aria-label />
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">{{ sdr ? sdr.volumeText : '80%' }}</output> <output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">{{ sdr ? sdr.volumeText : '80%' }}</output>
</label> </label>
<div id="sdr-status" class="radio-playback-state" :class="{ error: sdr?.statusError }" role="status" aria-live="polite">{{ sdr ? sdr.statusText : t('Connect an RTL-SDR to begin.') }}</div> <div id="sdr-status" class="radio-playback-state" :class="{ error: sdr?.statusError }" role="status" aria-live="polite">{{ sdr ? sdr.statusText : t('Connect an RTL-SDR to begin.') }}</div>