[feat](trx-frontend-http): virtual channel manager and picker UI
Add client-side virtual channel support (Phase 1 — metadata only):
- vchan.rs: ClientChannelManager keyed by rig_id; tracks per-session
channel subscriptions and broadcasts list changes via change_tx
- server.rs: instantiate Arc<ClientChannelManager> and expose as app_data
- api.rs: wire ClientChannelManager into /events SSE (session UUID,
init_rig, update_primary, channel change stream, session cleanup on
disconnect); add channel CRUD routes:
GET/POST /channels/{rig_id}
DELETE /channels/{rig_id}/{channel_id}
POST /channels/{rig_id}/{channel_id}/subscribe
PUT /channels/{rig_id}/{channel_id}/freq|mode
- auth.rs: classify /channels/ prefix as Read access
- plugins/vchan.js: channel picker with +/× buttons, subscribe on click,
SDR-only (shown when filter_controls capability is set)
- app.js: handle SSE `session` and `channels` events, call
vchanApplyCapabilities from applyCapabilities
- index.html: #vchan-row div + <script src="/vchan.js">
- style.css: .vchan-picker pill styles
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -303,6 +303,7 @@ function applyCapabilities(caps) {
|
||||
sdrSquelchSupported = false;
|
||||
}
|
||||
updateSdrSquelchControlVisibility();
|
||||
if (typeof vchanApplyCapabilities === "function") vchanApplyCapabilities(caps);
|
||||
}
|
||||
|
||||
const freqEl = document.getElementById("freq");
|
||||
@@ -2738,6 +2739,12 @@ function connect() {
|
||||
es.addEventListener("ping", () => {
|
||||
lastEventAt = Date.now();
|
||||
});
|
||||
es.addEventListener("session", evt => {
|
||||
if (typeof vchanHandleSession === "function") vchanHandleSession(evt.data);
|
||||
});
|
||||
es.addEventListener("channels", evt => {
|
||||
if (typeof vchanHandleChannels === "function") vchanHandleChannels(evt.data);
|
||||
});
|
||||
es.onerror = () => {
|
||||
// Check if this is an auth error by looking at readyState
|
||||
if (es.readyState === EventSource.CLOSED) {
|
||||
|
||||
@@ -259,6 +259,10 @@
|
||||
<div class="label"><span>VFO</span></div>
|
||||
<div class="vfo-picker" id="vfo-picker"></div>
|
||||
</div>
|
||||
<div class="full-row label-below-row" id="vchan-row" style="display:none;">
|
||||
<div class="label"><span>Channels</span></div>
|
||||
<div class="vchan-picker" id="vchan-picker"></div>
|
||||
</div>
|
||||
<div class="full-row label-below-row">
|
||||
<div class="label"><span>Signal</span></div>
|
||||
<div class="signal" style="gap: 1rem;">
|
||||
@@ -800,6 +804,7 @@
|
||||
<script src="/cw.js"></script>
|
||||
<script src="/bookmarks.js"></script>
|
||||
<script src="/scheduler.js"></script>
|
||||
<script src="/vchan.js"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/leaflet-ais-tracksymbol.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
// --- Virtual Channels Plugin ---
|
||||
//
|
||||
// Handles the `session` and `channels` SSE events emitted by /events and
|
||||
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
||||
|
||||
let vchanSessionId = null;
|
||||
let vchanRigId = null;
|
||||
let vchanChannels = [];
|
||||
let vchanActiveId = null;
|
||||
|
||||
function vchanFmtFreq(hz) {
|
||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||
return hz + "\u202fHz";
|
||||
}
|
||||
|
||||
// Called by app.js when the SSE `session` event arrives.
|
||||
function vchanHandleSession(data) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
vchanSessionId = d.session_id || null;
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad session event", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by app.js when the SSE `channels` event arrives.
|
||||
function vchanHandleChannels(data) {
|
||||
try {
|
||||
const d = JSON.parse(data);
|
||||
vchanRigId = d.rig_id || null;
|
||||
vchanChannels = d.channels || [];
|
||||
// If the active channel was evicted, fall back to channel 0.
|
||||
const ids = new Set(vchanChannels.map(c => c.id));
|
||||
if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
|
||||
}
|
||||
vchanRender();
|
||||
} catch (e) {
|
||||
console.warn("vchan: bad channels event", e);
|
||||
}
|
||||
}
|
||||
|
||||
function vchanRender() {
|
||||
const picker = document.getElementById("vchan-picker");
|
||||
if (!picker) return;
|
||||
picker.innerHTML = "";
|
||||
|
||||
vchanChannels.forEach(ch => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
||||
if (ch.id === vchanActiveId) btn.classList.add("active");
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "vchan-label";
|
||||
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
||||
btn.appendChild(label);
|
||||
|
||||
if (!ch.permanent) {
|
||||
const del = document.createElement("span");
|
||||
del.className = "vchan-del";
|
||||
del.textContent = "\u00d7";
|
||||
del.title = "Delete channel";
|
||||
del.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
vchanDelete(ch.id);
|
||||
});
|
||||
btn.appendChild(del);
|
||||
}
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
||||
});
|
||||
|
||||
picker.appendChild(btn);
|
||||
});
|
||||
|
||||
// "+" button — allocate a new channel at the current VFO frequency.
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "vchan-add";
|
||||
addBtn.textContent = "+";
|
||||
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||
addBtn.addEventListener("click", vchanAllocate);
|
||||
picker.appendChild(addBtn);
|
||||
}
|
||||
|
||||
async function vchanAllocate() {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
|
||||
// Use the last known rig frequency and mode as the starting point.
|
||||
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
|
||||
? lastFreqHz
|
||||
: 0;
|
||||
const modeEl = document.getElementById("mode");
|
||||
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const msg = await resp.text().catch(() => String(resp.status));
|
||||
console.warn("vchan: allocate failed —", msg);
|
||||
return;
|
||||
}
|
||||
const ch = await resp.json();
|
||||
vchanActiveId = ch.id;
|
||||
// The SSE `channels` event will trigger vchanRender(); optimistically
|
||||
// mark active so the picker feels responsive even before the event arrives.
|
||||
vchanRender();
|
||||
} catch (e) {
|
||||
console.error("vchan: allocate error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanDelete(channelId) {
|
||||
if (!vchanRigId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: delete failed", resp.status);
|
||||
}
|
||||
// Channel list updates via SSE `channels` event.
|
||||
} catch (e) {
|
||||
console.error("vchan: delete error", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function vchanSubscribe(channelId) {
|
||||
if (!vchanSessionId || !vchanRigId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: vchanSessionId }),
|
||||
}
|
||||
);
|
||||
if (!resp.ok) {
|
||||
console.warn("vchan: subscribe failed", resp.status);
|
||||
return;
|
||||
}
|
||||
vchanActiveId = channelId;
|
||||
vchanRender();
|
||||
} catch (e) {
|
||||
console.error("vchan: subscribe error", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Called by app.js from applyCapabilities().
|
||||
// Shows the channel picker only for SDR rigs.
|
||||
function vchanApplyCapabilities(caps) {
|
||||
const row = document.getElementById("vchan-row");
|
||||
if (!row) return;
|
||||
row.style.display = (caps && caps.filter_controls) ? "" : "none";
|
||||
}
|
||||
@@ -365,6 +365,43 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
|
||||
background: var(--btn-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.vchan-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.vchan-picker button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
height: var(--control-height);
|
||||
padding: 0 0.55rem;
|
||||
background: var(--input-bg);
|
||||
color: var(--text-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vchan-picker button.active {
|
||||
background: var(--btn-bg);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
border-color: var(--btn-border, var(--border-light));
|
||||
}
|
||||
.vchan-del {
|
||||
opacity: 0.5;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.vchan-del:hover { opacity: 1; }
|
||||
.vchan-add {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
padding: 0 0.6rem !important;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.signal-measure {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
Reference in New Issue
Block a user