Compare commits

..
Author SHA1 Message Date
sjg 84bdf2593c [fix](trx-frontend-http): measure whether the tab bar fits, and watch for it
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 8m25s
CI / frontend (push) Successful in 3m36s
CI / reuse (push) Successful in 3s
CI put the tab strip 9px into the controls at 1440px with no scaling at
all, on a bar that had every degradation step available to it and used
none of them.  It used none because nothing thought anything was wrong:
the fit test was an arithmetic estimate — identity + nav.scrollWidth +
actions.scrollWidth + a 48px allowance for the gaps — and on a platform
whose fonts run wider than the one it was written on, that allowance no
longer covered what it stands for.  An estimate that says "fits" stops
the ladder before its first rung.

It reads the geometry now: the controls have to stay inside the bar, and
no tab may reach them.  That is the same measurement the test makes, so
the two cannot disagree about any platform's metrics.  The tabs are the
subject rather than the nav's box because the nav shrinks below its
content — the box gets smaller while the tabs keep their width and slide
underneath the controls.

A second fault turned up while probing this: the strip only reflowed on
window resize.  The rig name arriving from the server, the style picker
filling in, a font swapping in wider metrics — each changes what fits
without touching the window, and the bar sat there as it was through all
of them.  A ResizeObserver on the bar and the controls covers those, and
document.fonts.ready covers the swap.

The guard sweeps text scales and adds a station name too long for the
bar, but it should be said plainly: it passes against the old code too.
Nothing here reproduces on this machine — a 4px viewport sweep from 1080
to 1500, three wide font stacks and scales from 1.0 to 3.0 all failed to
make the old estimate lie.  What is fixed is the mechanism that could.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 20:32:38 +02:00
sjg c527f20a88 [feat](trx-frontend-http): give digital modes a decoder sidebar
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
Thirteen decoders in a horizontal strip needed a scroller on anything
but a wide window, and the open one was marked by a single underline
among thirteen.  They are a list down the left now, all visible at once,
each keeping the state dot it already carried, with the panel for the
selected one filling the rest of the width.

No script changed: the sub-tab wiring, the aria roles, the decoder
picker and the state-dot observers all work on the same markup, so this
is layout only.

Below 760px the sidebar gives way to the picker that already existed
there.  That path needed align-content: the tab panel fills the page
height and a grid stretches its rows to match, which handed the picker a
218px row and left a 189px gap under it.

The tab icon was the signal-strength bars, which is what the S-meter
shows two rows above it; a pulse train says digital modes instead.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 08:01:22 +02:00
sjg 9f495021f0 [style](trx-frontend-http): name and fence the three audio groups
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m23s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
The row carries three unrelated things — how loud it is, whether there
is any audio at all, and how much is arriving — and only the middle one
was named.  Each is a group now: VOLUME over the two sliders, SQL on its
own switch, LEVEL over the meter, with a hairline between each.

The rules are drawn only while the row is one line, measured against the
row rather than the viewport: what fits depends on whether the rig
transmits and whether it has a squelch at all.  A rule divides what sits
either side of it, so once a group wraps the wrap is the division and
the rule would just be a mark at the start of a line — which is what it
was at 900px before this.  The labels carry the grouping on their own
below that width, and the groups stack whole on a phone.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:46:56 +02:00
sjg 33aa7b807c [style](trx-frontend-http): fence the squelch off from the volume controls
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m18s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
The squelch sat in the audio row on the same gap as everything else, so
it read as a continuation of the volume sliders.  It decides whether
there is audio at all, which is not the same kind of control as how loud
it is, and a hairline says so.

The rule belongs to the squelch block, so it leaves with it on a rig
that has none, and it stands down where the row stacks: there the line
break separates them already and a leading rule would just start a line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 07:36:53 +02:00
sjg 2c56d82a81 [feat](trx-frontend-http): make the SQL label the squelch switch
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
Clicking SQL turns the squelch on and off.  The label and the button
beside it said the same thing twice — one naming the control, the other
reading "On" or "Off" — where the name itself is the obvious target, and
the dot already carries the state: grey when off, green while the gate
passes, amber while it holds.

The pressed state is on the label, so the switch reads the same to a
screen reader as it looks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:52 +02:00
sjg 0fc2115973 [fix](trx-frontend-http): make map links work before the map has loaded
The AIS and APRS mini views link each position to the map, and neither
did anything: the map module installs itself lazily, and it was the one
defining window.navigateToAprsMap, so until something had opened the Map
tab the global did not exist.  AIS calls it inline from onclick and
threw "not a function"; APRS guards the call and so failed silently.
The grid links on FT8, FT4, FT2 and WSPR rows went the same way through
navigateToMapLocator.

The app owns both globals now, installed at startup.  They record the
target, switch tabs through navigateToTab — the only path that
materialises the panel from its template, loads the module and updates
the history entry, none of which the module's own hand-rolled tab switch
did — and the target is applied once the module reports ready.

The module keeps the focusing, which is its job, and exposes it as
focusMapPosition and focusMapLocator.

The smoke test now calls the link from a cold page, asserting the map
module is not loaded first so the check cannot pass by accident.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:41:09 +02:00
sjg 2c1df75d19 [fix](trx-frontend-http): measure auto squelch from the meter
CI / frontend (push) Successful in 3m16s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m18s
CI / test (push) Successful in 8m14s
Auto took the spectrum's noise floor and added 6 dB, but the threshold
is compared against the channel level the meter reports, and the two sit
a long way apart: the gap is set by the FFT size and window, the channel
bandwidth, the decimation, and peak-versus-mean statistics.  Measured on
white noise it runs +22.1 dB at 48k/8k/3k, +18.7 dB at 240k/24k/12k and
-1.2 dB at 1.92M/24k/12k — a 23 dB swing across ordinary configurations.
Only the last of those is anywhere near right, so on a narrow span Auto
set the gate some 20 dB below the noise and it never closed.

It now reads the same number the DSP compares: the 20th percentile of
the meter over the last ten seconds, plus 5 dB.  The percentile keeps a
burst of traffic inside the window from dragging the estimate up, and
5 dB clears the meter's own jitter, which measured 0.9-1.6 dB.  Nothing
in it converts between scales, so no part of the signal chain can put it
out again.  With no history yet — a fresh connection, a rig switch — it
listens for a moment rather than refusing.

The fixture gained a streaming /meter, without which there is nothing to
measure, and the spectrum test pins auto to the meter it serves.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:32:18 +02:00
sjg aefd36c4b1 [feat](trx-frontend-http): set the squelch on the spectrum, in dB
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m41s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
The threshold is in dB, and since the squelch fix that is the scale the
spectrum axis and the S-meter are labelled in — so the control belongs
on the plot, at the level it gates.  A dashed line spans the spectrum at
its threshold with a grip that reads it out, dragged like the bandwidth
edges, green while the signal is above it and amber while it gates.
Arrow keys move it a dB at a time for anyone not using a mouse.

The audio row keeps a compact version: the dB, an indicator lit from the
same meter the DSP compares against, Auto, and an enable toggle that no
longer doubles as the level.  The slider ran 0-100% over that dB range,
which gave the operator a number with nothing on screen to relate it to,
and zero meant "disabled", so turning the squelch off to listen threw
the threshold away.  Auto now says which level it picked.

Two things the browser could only show once it was on the plot: the grip
landed underneath the split control at the right edge, which swallowed
its pointer, and dragging to the foot of the axis hid the line — and the
grip with it — instead of pinning it where it could be dragged back.

The fixture could not exercise any of this: /audio answered 404, which
hides the audio row and the control inside it, and the status carried no
filter block, which is what tells the client the rig has a squelch at
all.  Both now look like an SDR, and the spectrum test drives the line.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:20:19 +02:00
sjg 25b9c31c9b [fix](trx-backend-soapysdr): squelch against the meter, not the post-AGC level
The threshold was compared against the block level measured after the IQ
AGC.  Holding that level at a setpoint is the AGC's entire purpose, so
for every mode that has one — FM, PKT, AIS, AM, SAM, which is to say the
modes anyone squelches — the comparison was against a near-constant.
With FM's 12 dB of gain a weak signal reads some 12 dB hotter than it
is, and the value never had the decimation correction the meter applies
on top of that: around 20 dB adrift at 48k/8k, more as decimation grows.

The threshold arrives in the other scale entirely.  The slider maps its
percentage onto -120..-30 dB and Auto takes the spectrum noise floor
plus 6, both of which are what the meter and the spectrum display show.
So a gate set just above the noise sat open on it.

It now reads last_signal_db, which is already computed each block before
the AGC and corrected for decimation — the same number the meter shows.
The post-AGC measurement had no other consumer.

The test feeds one signal twice and takes the threshold from the
channel's own meter: 6 dB above must gate it, 6 dB below must pass it.
Nothing there depends on the absolute scale, only on the two agreeing.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 23:03:46 +02:00
sjg d68a84f7f9 [chore](trx-client): apply cargo fmt
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 3m18s
CI / reuse (push) Successful in 3s
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:57:33 +02:00
sjg 4fa191e9b6 [fix](trx-frontend-http): serve the band plan to every session
CI / lint (push) Failing after 1s
CI / test (push) Successful in 8m20s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
/bandplan.json needed the control role.  Route access is decided by
suffix for static assets — .js, .css, .png and so on — and ".json" is
not among them, so the band plan matched nothing and fell through to the
catch-all.  It is compiled into the binary and identical for every user,
so it is public now, like the rest of them.

Two things followed from that.  Read-only sessions never saw a band plan
at all.  And since the page asks for it during startup, the request can
land before the session is established: that 401 was swallowed by an
empty catch and never retried, which is why the allocations sometimes
only appeared after a manual reload.

So the client no longer hides the failure, retries once the auth gate
clears — which is exactly when a startup 401 becomes fixable — and
schedules a draw when the data lands, since the strip is painted from
the spectrum draw and a rig sitting between frames would stay blank.

The fixture can now refuse the first request the way the server did, and
the spectrum layout test holds the client to recovering from it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:50:49 +02:00
sjg c073d03ffb [feat](trx-frontend-http): reorder the controls tray sections
CI / lint (push) Successful in 2m23s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 3s
CI / test (push) Successful in 8m14s
The radio's own settings now come first, then audio, then the scheduler:
Advanced radio controls, Audio controls, Scheduler controls.

The advanced section is not in the markup — ui-core builds it at runtime
and gathers the SDR settings, virtual channel and TX limit rows into it,
appending the result, which put it last however the markup was ordered.
It is inserted ahead of the audio section instead.

The signal readout and the TX meters stay where they are, between the
controls and the sections: they are readouts rather than a section, and
on an SDR the spectrum covers them anyway.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:41:08 +02:00
sjg 730f129404 [fix](trx-frontend-http): let the header identity give way before the tabs
CI still put the tab strip into the controls, now at 1100px — the
narrowest bar in the app, since the bookmark gutters take 9.5rem a side
above that width and leave 756px against 871px at 900px.  With the
controls already in the overflow menu and the tabs already down to
icons, nothing else could give, and what gives by default is the strip:
it is the one item allowed to shrink below its content, so its tabs keep
full width and slide under the controls, out of reach.

The identity block takes the squeeze instead, ellipsised.  A clipped
station name is still readable; a destination hidden underneath the
controls is not.

The guard that was supposed to catch this scaled only the tabs and the
controls, not the title and subtitles — which is exactly what runs out
of room — and skipped 900px.  It now scales every piece of text in the
bar and checks all four widths.  Measured across text scales from 1.0 to
3.0 at each width, the bar keeps its 16px allowance everywhere; before
this, 1.6 and above overlapped at 1100px.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:40:36 +02:00
sjg f95f3d0104 [feat](trx-frontend-http): centre the radio controls, split off the mode row
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m13s
CI / frontend (push) Failing after 32s
CI / reuse (push) Successful in 3s
The controls every rig has — mode, wheel, tune step, transmit — now sit
as a centred block rather than packed against the left edge.

What the current mode adds moves out from among them: WFM's six controls
stretched the row sideways whenever it was active, pushing the wheel and
the step pickers off centre, and SAM did the same on a smaller scale.
They get a row of their own below a divider, which appears and leaves
with them — an empty one would still take a track and a gap in the tray
and draw its divider under controls it has nothing to do with.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:29:31 +02:00
sjg e70e82c8c0 [feat](trx-frontend-http): rebuild the general radio controls row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m8s
CI / frontend (push) Failing after 36s
CI / reuse (push) Successful in 4s
Mode was a full-width select: 483px of the row to display "FM".  The
modes are three or four characters and there are at most twelve, so they
become a segmented group like the Unit and Step Scale pickers beside
them — a third of the width, and one click instead of two.

The <select> stays as the mode's value.  A dozen call sites and several
plugins read #mode.value, so replacing it outright would have reached
much further than a layout change should; it is hidden from sight and
from assistive tech, the buttons write to it, and everything downstream
runs unchanged.  Every writer re-syncs the buttons, the plugins through
a new trxCore.syncModePicker.

The row itself was a grid with a track per column, but the WFM, SAM and
transmit columns are hidden on most rigs, so it ended in some 500px of
hole.  It packs left now.  Same fault one level down: the power buttons
sat in three fixed tracks, so a rig with neither transmit nor lock kept
two empty ones and left its label chip stranded at the far edge.

Unit and Step Scale move out of the frequency row and in beside the
wheel and the +/- they modify, which were some 600px away.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 22:09:08 +02:00
sjg 23cc0db1fa [fix](trx-frontend-http): drop the tabs to icons when the bar runs out of room
CI / test (push) Successful in 8m15s
CI / frontend (push) Failing after 31s
CI / reuse (push) Successful in 2s
CI / lint (push) Successful in 2m17s
CI put the tab strip 9px into the controls at 1440px on a run that
passed locally: its system font is wider, and the bar had no move left
to make.  Controls are moved into the overflow menu until the bar fits,
but once all of them were in the menu nothing else gave — the nav may
shrink below its content, so the tabs kept full width and ran under the
controls, leaving the destinations nearest them unclickable.  Labels
dropping to icons was the other half of the answer, but it hung off a
max-width:1360px media query and so was unavailable at 1440px.

That class now goes on by measurement, as the last step after the menu
is exhausted, which is the same reasoning the controls' own fit test
already uses: how much fits depends on the rig name and on how wide the
platform draws the labels, not on the viewport.  The class is cleared
before measuring so the decision cannot ratchet, and icon widths are
fixed, so it always buys back the labels' width.

Labels now stay put between 1100px and 1360px while they fit, with the
style picker and theme toggle behind the overflow menu instead.

The suite could not have caught this: it passed on the fonts of the
machine that wrote it.  The layout section now repeats its fit check
with the bar's text scaled up, which reproduces a wider system font
anywhere — with this fix reverted it fails on macOS too.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:36:55 +02:00
sjg 657f952a2c [fix](trx-frontend-http): hold the strips' place above the spectrum
Tuning across a band edge moved the whole page under the cursor: the
band plan strip is in flow, so a range with no allocations collapsed it
from 18px to nothing and dragged every element below it up (measured at
1600x950: overview top 118 to 100, footer 1026 to 1008).  It now keeps
its height whenever a band plan could be drawn at all, and only gives it
back when the feature is off, has no data, or there is no spectrum.

The bookmark rail gets the same treatment for consistency, though it
never moved anything — it is absolutely positioned over the top of the
overview.  It stays up and blank rather than vanishing.

Which exposes something the rail was already doing wrong: it covers the
top of the plot, and a bare div still takes pointer events, so whenever
bookmarks were in range that band of the overview could not be dragged
or scrolled.  Only the chips are targets now.

tests/spectrum-layout.mjs covers this: it streams spectrum frames, tunes
between a band with bookmarks and allocations and one with neither, and
asserts nothing moves and that the rail lets clicks through.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:19 +02:00
sjg 1f256cbb68 [refactor](trx-frontend-http): extract the browser test fixture
browser-smoke.mjs carried its static server inline, which made it the
only browser test that could exist: a second one would have had to copy
180 lines of routes to change a single capability flag.  The server
moves to tests/web-fixture.mjs behind startWebFixture(), with the rig's
spectrum support, bookmarks and band plan as options.

Serving a rig with a spectrum matters because that is where the layout
actually lives — the panel, the strips above it and the waterfall are
all gated on filter_controls, and the existing fixture reports a
CAT-only rig, so none of it has ever been rendered under test.

No change to what the smoke test checks.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 21:26:06 +02:00
sjg 92fbdb692c [feat](trx-frontend-http): lay the scheduler controls out in one row
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Failing after 29s
CI / reuse (push) Successful in 3s
The controls were a column — release, then the step buttons, then the
status line, then the entry on air last — which read bottom-up and left
the entry that is actually transmitting furthest from the buttons that
change it.  They now run left to right: step through the entries, hand
the rig back, then the current entry behind a separator.

The separator is a pseudo-element on the current-entry block rather than
an element of its own, because that block is display-toggled whenever
fewer than two entries are active; a standalone rule would be left
hanging with nothing after it.

No ids move, so the enable/disable logic in the scheduler plugin and the
release polling in vchan bind exactly as before.  The smoke test asserts
the row's order, which is also what keeps the separator in place.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:58:31 +02:00
sjg b2fbcb318d [fix](trx-frontend-http): stop Tools lighting up on every refresh
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m21s
CI / frontend (push) Failing after 30s
CI / reuse (push) Successful in 3s
navigateToTab marked the Tools button by asking whether the destination
tab was displayed, which is the right question at the wrong moment: the
first route navigation runs while the card is still behind the loading
state, where every tab computes to display:none.  Refreshing or deep
linking to any page therefore lit Tools alongside the real destination,
and nothing re-evaluated it once the page appeared.

Membership of the Tools menu answers the same question without needing
anything laid out, and still reads the grouping ui-core installs rather
than a second copy of it.

The smoke fixture now serves the SPA shell for route paths the way the
server's per-tab index handlers do, so a deep link no longer 404s and
the case is testable at all; two of them are asserted.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:51:04 +02:00
sjg 44721b579c [fix](trx-frontend-http): fill the map column down to the footer
The windowed map was capped at 75% of the viewport height and at a
width-derived aspect ratio, which left a dead band under it: 69px at
1600x950, and on a 420px-wide phone a 270px map on an 800px screen.
Neither cap was doing useful work now that the stage spans the full
width, so the map fills the column down to the footer instead.

Growing into the footer needs a bound: once the column is tall enough to
push the footer below the fold, using its position would push it further
on every pass, so the bottom edge is clamped to the viewport.  Growth
then consumes the column's spare height and settles in one pass.

Also drops three mapIsFullscreen() branches in the windowed path that
could never be taken — the fullscreen case returns above them.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:50:48 +02:00
sjg 889d00007d [feat](trx-frontend-http): box the selected tab instead of underlining it
CI / lint (push) Successful in 2m23s
CI / test (push) Successful in 8m19s
CI / frontend (push) Successful in 3m10s
CI / reuse (push) Successful in 3s
The desktop strip marked the current page with a 2px underline while the
mobile bottom nav already boxed it, so one navigation model looked like
two.  The box now sits on both: a transparent 1px border on the base
reserves it, so switching pages moves no neighbours, and hover fills a
fainter version of the same shape.  Tools carries it too — that button
is marked active for the destinations the strip hides.

Dropping the mobile rule's border-bottom:none, which only existed to
cancel the old desktop underline, closes the bottom edge its active box
had been missing.  The smoke test checks all four edges.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:31 +02:00
sjg 6f53592041 [feat](trx-frontend-http): rework the page footer
The footer floated in space below the content with no rule to close the
page, its two clusters sat on a text baseline that left the source pill
hanging, and the status hint was a plain line of text a size larger than
the attribution beside it.

Now a hairline closes the page the way .tab-bar opens it, the clusters
centre on one line, and the attribution drops the opacity it stacked on
top of --text-muted, which had put it below a readable contrast ratio.

The status hint becomes a pill with a state dot: green when ready, amber
while a command is in flight, red on connection loss.  The colour comes
from a data-state attribute, so every hint now goes through setPowerHint
instead of assigning textContent directly.  --status-ok carries the
indicator green; .about-status-on picks it up too, which darkens it on
light themes where the old value was barely legible.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:37:12 +02:00
sjg 709939f60b [feat](trx-frontend-http): span the map across the full viewport width
The map stage broke out of the centred .card column: negative inline
margins cancel the card's centring offset and its side padding, so the
stage reaches both viewport edges at every width without hardcoding
either value.  Its rounded corners and left/right borders go with it —
edge to edge, the panel reads as a band rather than a floating card.

The browser smoke test now measures the stage against the viewport, and
checks that the full-bleed width does not push the page sideways.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 20:36:50 +02:00
sjgandClaude Opus 5 5b7dd493d4 [feat](trx-frontend-http): draw APRS symbols from the sprite sheets
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m12s
CI / frontend (pull_request) Successful in 3m2s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
Resolve a table/code pair to a sprite cell in aprs-shared, and use it
from both the packet lists and the map markers, which had each been
printing the raw symbol character in a bordered box.

A table identifier of / or \ selects the primary or alternate sheet
directly.  Anything else is an overlay character, which the APRS spec
draws on top of the alternate symbol -- so those stack the overlay sheet
over the alternate one rather than picking a sheet.  Codes outside
0x21..0x7E have no cell and keep the old character box.

The sheet URLs stay in the stylesheet so a min-resolution query can swap
in the retina sheets; only the cell offset is computed and set inline.
Map markers share the helper through the plugin chunk, so the map stays
free of any remote symbol fetch.

Verified in a browser against the real stylesheet and sheets: /> is a
car, /_ a WX circle, /& an igate diamond, \n a red triangle, and the
overlays S> and 7# carry their character on the alternate symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 02fe492dbf [feat](trx-frontend-http): serve the vendored APRS symbol sprites
Embed the six sheets alongside the other vendored assets and serve them
under /vendor with the same immutable cache headers.

The browser computes a symbol's cell from a 16x6 grid of 24px cells, so
a re-vendored sheet at any other size would shift every station onto a
neighbouring icon -- wrong on every packet, and invisible unless you
know which glyph to expect.  Pin the geometry by parsing each embedded
PNG's IHDR in a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:55:50 +02:00
sjgandClaude Opus 5 1bc2c88e2f [chore](trx-rs): vendor the APRS symbol sprite sheets
The web UI had no symbol graphics at all, so a position report rendered
its raw symbol character in a bordered box.  Vendor rev H of the
hessu/aprs-symbols set: three 24px sheets (primary, alternate, and the
overlay characters) plus their retina variants.

The set carries no single license.  Individual symbols are variously
vectorizations of the original WA8LMF bitmaps with unknown terms, new
CC BY-SA work by OH7LZB, public-domain or CC sources, and a handful of
brand logos owned by their companies.  Record that as
LicenseRef-APRS-Symbols with the upstream per-symbol catalogue copied
verbatim, and carry the attribution pointer upstream asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 19:36:27 +02:00
sjgandClaude Opus 5 863a6d8fd4 [fix](trx-frontend-http): restore decode history from the stored records
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m19s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 2s
Replay required every restored record to carry a string `type`, and
stored records do not have one: an AIS entry holds mmsi, lat, lon,
crc_ok and its decoder's own fields, nothing more.  The filter therefore
discarded all of them, and did it silently — the fetch returned its full
payload, the worker decoded it, and no error was logged, so the history
simply never appeared.

That field identifies live SSE frames, which do carry it, which is why
only replay was affected.  History arrives already grouped and the
group's kind is delivered alongside the messages, so `type` was never
needed to route them.  Require only that a record is an object.

Confirmed against a live server: the first restored group is AIS, and
its records expose their decoder fields with `type` undefined.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 18:39:13 +02:00
sjgandClaude Opus 5 b252717f6b [test](trx-frontend-http): serve a realistic decoder registry to the smoke test
CI / lint (pull_request) Successful in 2m18s
CI / test (pull_request) Successful in 7m25s
CI / frontend (pull_request) Successful in 2m10s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m26s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The fixture answered /decoders with an empty list, which hid most of the
application from the only test that runs it in a browser.  The decoder
sub-tabs, their panels, the decode toggles and the bookmark decoder
checkboxes are all built from that registry, so the run exercised three
of thirteen sub-tabs and none of the decoder UI.  Finding this needed
route interception, because nothing in the suite could see it.

Serve eleven decoders covering the modes the real registry spans.  The
run now builds 13 sub-tabs and 11 bookmark decoder checkboxes — the same
checkboxes whose construction a recent fix changed without any test
reaching them — and still reports no runtime errors.

It also makes an existing fault observable: at 1100px the decoder
sub-tab bar hides 195px of itself with no scrollbar or fade, the same
silent truncation the top strip had.  No assertion for it here, since
that would fail until the truncation is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 17:46:52 +02:00
sjgandClaude Opus 5 92697b11c5 [feat](trx-frontend-http): mark Tools active for its destinations
CI / test (pull_request) Successful in 8m9s
CI / test (push) Successful in 7m22s
CI / lint (pull_request) Successful in 2m15s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m16s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 2s
Grouping Statistics, Recorder, Settings and About behind Tools left the
tab strip looking identical on all four: the destination's own button
carries the active class, but the strip hides that button, so nothing
was marked.  The page titles named the page without saying how you got
there.

Mark the Tools button when the active destination is one the strip hides.
That state is read from the button's computed display rather than from a
second copy of the grouping, so the two cannot drift: whatever ui-core
puts in the menu lights up Tools, and a destination promoted back into
the strip stops doing so with no further change.

Tools already carries the tab class, so the existing active styling
applies unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:23:17 +02:00
sjgandClaude Opus 5 b409c57296 [test](trx-frontend-http): assert header geometry in the browser smoke test
CI / test (push) Successful in 7m27s
CI / frontend (push) Successful in 3m1s
CI / reuse (push) Successful in 3s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 7m24s
CI / frontend (pull_request) Successful in 2m11s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m15s
Several layout faults shipped while every gate passed, because nothing
looked at geometry: a header whose height tracked the viewport, controls
at four different heights, a tab strip that ran under the controls, and a
dropdown that opened underneath the page.

Assert the invariants behind those at four widths — the header stays one
row, the tabs do not reach the controls, the controls share a height, the
page does not scroll sideways — and that the menu renders with real
dimensions and wins a hit test at its own centre.

The overlap check measures the tabs rather than the strip: with the strip
allowed to overflow, its box shrinks while its content paints across the
controls, so the container's own rect never registers the collision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:18:16 +02:00
sjgandClaude Opus 5 00191c8d7a [fix](trx-frontend-http): serve the Statistics and Bookmarks routes
CI / lint (pull_request) Successful in 2m18s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m17s
CI / test (pull_request) Successful in 8m23s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 2s
The server answers /, /map, /digital-modes, /recorder, /settings and
/about with the application shell, but never had a route for /statistics
or /bookmarks.  Both fell through to the catch-all asset handler, so
reloading on either one downloaded a file instead of reopening the page.
Only in-app navigation worked, which is why it went unnoticed until
Statistics was reachable from the Tools menu.

Add the two missing shell routes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-03 00:03:48 +02:00
sjgandClaude Opus 5 56c363a517 [fix](trx-frontend-http): align the Statistics page and unblock the tab strip
Two faults, both mine, both visible in one screenshot of that page.

#tab-statistics was the only panel with padding of its own, so its title
and content sat 16px inside where every other page begins.  Remove it and
the page lines up with the header and with its siblings.

Removing the tab strip's `overflow-x` left it unable to shrink below its
content, so at around 1280px it ran under the controls: the Map tab sat
beneath the audio button and Tools beneath REC.  Clipping is safe again —
the menus it anchors are reparented to the body when they open — so the
strip can shrink, and the labels now give way to icons at 1360px rather
than 1180px, before it has to clip anything.

Measured at 1280px: 321px of clearance between the strip and the
controls, and the page title at the same left edge as the header.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:59:58 +02:00
sjgandClaude Opus 5 5ad91b4ab6 [fix](trx-frontend-http): stop doubling the space under the Statistics title
CI / lint (pull_request) Successful in 2m17s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m12s
CI / reuse (push) Successful in 3s
The page titles carry a bottom margin, which is what spaces them from the
content on the plain block panels.  #tab-statistics is not one: it is a
flex column with `gap: 1rem`, so the margin landed on top of that gap and
left 28px under the title where every other page had 12px.

Drop the margin on that panel and let its own gap do the spacing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:50:03 +02:00
sjgandClaude Opus 5 1843522b45 [feat](trx-frontend-http): give the Tools destinations page titles
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m21s
CI / test (push) Successful in 7m30s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
Recorder stated its name; Statistics, Settings and About did not, so one
page in eight carried a title.  The class it used, section-heading, had
no rule behind it either, leaving even that title as a default h2.

Which way to unify follows from the navigation change.  The tab strip
highlights the destination you are on, so Radio, Bookmarks, Digital modes
and Map already say where you are and a title would repeat the strip
while costing vertical space the spectrum wants.  The four destinations
behind Tools get no such highlight — the strip looks the same on all of
them — so those are exactly the pages that have to name themselves.

Give the three that were missing a heading, and style section-heading so
all four match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:46:09 +02:00
sjgandClaude Opus 5 41ddecf5a7 [fix](trx-frontend-http): label the overflow tab Tools and hide its glyph
CI / test (pull_request) Successful in 8m17s
CI / lint (push) Successful in 2m18s
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 3m6s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
The button rendered as "•••More": the dots span carried no styling at
all, so the glyph sat flush against the label instead of behaving like
the icon it is.  Every other tab hides its icon while labels are shown
and swaps to it when they are not; the dots now follow the same rule, so
the button reads "Tools" beside the other labels and becomes the glyph
alone in the icon band.

"More" also said nothing about the destinations behind it.  The menu
holds Statistics, Recorder, Settings and About, so name it Tools and give
the button an aria-label that spells that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:36:41 +02:00
sjgandClaude Opus 5 b06c37affa [fix](trx-frontend-http): lift the header menus out of the header
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m21s
CI / frontend (pull_request) Successful in 3m5s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m36s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
Fixed positioning escaped the clipping, but not the stacking: the header
carries `z-index: 2`, which makes it a stacking context, so whatever
z-index a menu inside it carries is confined below level 2.  The spectrum
overlays paint as high as 9600, so both dropdowns opened underneath them.

Reparent each menu to the body when it opens.  Leaving that subtree is
the only way out of an ancestor's stacking context, and the menus are
already positioned in viewport coordinates, so nothing else about them
changes.  The outside-click test now considers the menu as well as its
wrapper, since the two are no longer nested.

Verified by hit testing rather than by inspecting z-index:
elementFromPoint at the open menu's centre returns the menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:28:39 +02:00
sjgandClaude Opus 5 b4912f5879 [fix](trx-frontend-http): render the header menus above the page
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m23s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 3s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
Both header dropdowns were laid out inside the bar rather than over the
page.  The navigation menu opened as an 18px sliver positioned above its
own button, and the overflow menu did not appear at all.

Two causes.  The tab strip kept `overflow-x: auto` from when it scrolled,
which clips an absolutely positioned descendant — and the strip is what
the navigation menu anchors to.  The strip no longer scrolls, since the
occasional destinations moved behind More, so the property and the edge
fade that went with it are both gone.

Anchoring in fixed coordinates at open time addresses the general case:
an absolutely positioned menu is clipped by any scrolling ancestor and
trapped inside whatever stacking context its ancestors create, so it can
be squashed inside the bar or painted underneath page content.  Fixed
coordinates answer to the viewport, and the menu flips above its button
near the bottom edge.

Clearing `right` when setting `left` keeps the menus at their natural
width: the stylesheet pins them to the right of their anchor, and leaving
that in place stretched them across the bar — 845px for a four-item list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 19:18:47 +02:00
sjgandClaude Opus 5 27f2558193 [feat](trx-frontend-http): one navigation model at every width
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 3m0s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / lint (pull_request) Successful in 2m18s
CI / lint (push) Successful in 2m17s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
Eight destinations sat flat in the tab strip with equal weight, competing
with the controls for the same row and then scrolling out of reach with
only a fade to say so.  They are not equal: Radio is where an operator
spends nearly all their time, Bookmarks, Digital modes and Map are
operating surfaces, and Statistics, Recorder, Settings and About are
occasional.

The mobile layout already grouped them exactly that way, behind its More
menu, so the application carried two navigation models.  Adopt the mobile
grouping at every width instead of adding a third: four operating tabs
plus More.  The strip no longer scrolls at any width, and the menu keeps
its bottom-sheet placement on mobile while anchoring under its button
elsewhere.

Drop the labels between 701 and 1180px so the tabs degrade to their icons
— which every tab already carries — before the strip could ever need to
hide a destination.

Rename Main to Radio: it is the receiver, not a generic first page, and
the name now says what the destination is rather than where it sits.

Freeing that width also let the style picker and theme toggle return to
the bar inline, leaving only the layout picker in the overflow menu.

Navigating to About in the browser smoke test now goes through More, as a
person would.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 19:12:14 +02:00
sjgandClaude Opus 5 3a9bf1b7ce [fix](trx-frontend-http): drop the rig description from the top bar
CI / test (pull_request) Successful in 8m22s
CI / frontend (pull_request) Successful in 3m1s
CI / reuse (pull_request) Successful in 3s
CI / lint (pull_request) Successful in 2m17s
The header repeated the active rig's hardware string and mode list beside
the rig picker.  With a real SDR that reads

  SoapySDR driver=airspyhf,serial=c852eb5dd23539f8 · RX · SDR filters ·
  LSB · USB · CW · CWR · AM · +7 modes

which is longer than every other control in the bar combined, and it is
already on the About tab in full, split across its Rig, Active rig,
Connection, Modes and VFO rows.

Remove the element and the builder behind it.  Rig switching keeps its
feedback through the existing hint channel rather than by briefly
rewriting a permanent label, and the identity that belongs in a header —
the rig's display name — stays in the left subtitle.

The freed width is not spent: the tab strip now reaches Settings before
it needs to scroll, where it previously faded out during Statistics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 18:59:28 +02:00
sjgandClaude Opus 5 dd5760c436 [style](trx-frontend-http): fade the scrolled tab strip edge
CI / lint (pull_request) Successful in 2m19s
CI / test (pull_request) Successful in 8m17s
CI / frontend (pull_request) Successful in 3m0s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m33s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
The page tabs scroll rather than wrap, so the last visible tab was sliced
mid-word ("Se…" for Settings), which reads as a rendering fault instead of
as an invitation to scroll.

Fade the trailing edge with a mask.  A colour-matched cover gradient is
the usual trick, but the card is transparent, so a cover would have to
track the page background across both themes and all nine styles; a mask
is colour-agnostic.  Only the trailing edge is faded, leaving the first
tab crisp while the strip sits at rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 18:48:34 +02:00
sjgandClaude Opus 5 eee3630f04 [feat](trx-frontend-http): compact single-row top bar
The header's height depended on the viewport width, and not even
monotonically: 112px at 1440, 169px at 1100, 131px at 900, 246px at 720.
Both control groups wrapped, so every width produced a different ragged
block — eight page tabs across four rows at 1100px, and action controls
across three.  Four different control heights (32, 34, 45 and 54px) sat
in the same row, the 54px one being the rig picker with its summary
stacked underneath, and on narrow viewports the icon buttons stretched to
fill half the row, rendering a play triangle centred in a 249px box.

Lay both groups out as one row that never wraps.  Controls are a uniform
2rem and no longer stretch, the rig summary sits inline beside its select,
and the page tabs scroll instead of wrapping.  Secondary controls —
layout, style and theme — move into an overflow menu when the bar cannot
hold them, leaving audio, record and the rig picker inline.

Deciding when they no longer fit needs natural widths, not rendered ones:
the nav has min-width 0 and scrolls, so it always shrinks to the leftover
space and always reports scrolling, and the bar reports overflow even when
nothing is clipped.  scrollWidth on the scroll container is its
unconstrained content width, which is what the fit test compares against
the space available.

Measured after the change: 72px at 1440, 1280, 1100, 900 and 480, every
control 32px, nothing clipped at any width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 18:45:43 +02:00
sjgandClaude Opus 5 b31790ff48 [fix](trx-frontend-http): keep layout sections togglable
CI / lint (pull_request) Successful in 2m21s
CI / frontend (pull_request) Successful in 3m3s
CI / reuse (pull_request) Successful in 3s
CI / test (pull_request) Successful in 8m32s
CI / lint (push) Successful in 2m30s
CI / test (push) Successful in 8m36s
CI / frontend (push) Successful in 2m13s
CI / reuse (push) Successful in 4s
A layout seeds the collapsible sections; it should not hold them there.
applyLayout writes the disclosure state of the advanced, audio and
scheduler sections, and it runs far more often than a layout change:
render() calls applyRigList() for every SSE frame carrying `remotes`,
which calls setActiveRig() unconditionally, which re-applies the layout.

An operator who expanded a section that the selected layout collapses by
default therefore had it shut again within about a second, which read as
the section being locked by the layout — most visibly the scheduler under
Compact.

Write the section state only when the layout actually changes, or the
first time each section appears in the DOM, since the advanced controls
are constructed after the first applyLayout call.  Switching layout still
reseeds every section, so choosing a layout keeps meaning "give me these
defaults".

Verified in Chromium: with Compact selected, activating the scheduler
summary opens the section and it survives both a rig-state refresh and a
repeated applyLayout, while selecting Full still reseeds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 17:46:56 +02:00
sjgandClaude Opus 5 2f4973ed70 [chore](trx-rs): allow the sccache bind mount on the CI runner
CI / test (pull_request) Successful in 13m51s
CI / frontend (pull_request) Successful in 5m1s
CI / test (push) Successful in 7m43s
CI / frontend (push) Successful in 2m18s
CI / reuse (pull_request) Successful in 4s
CI / lint (pull_request) Successful in 4m22s
CI / lint (push) Successful in 2m23s
CI / reuse (push) Successful in 1m18s
act_runner validates every bind mount against `valid_volumes`, which
defaults to an empty allowlist, so the `-v /var/cache/sccache:/sccache`
in `container.options` was dropped on every job.  The only trace is one
line in the job log — "[/var/cache/sccache] is not a valid volume, will
be ignored" — after which SCCACHE_DIR points at a path that does not
outlive the container, so the shared compilation cache never persisted.

Allow that one path rather than the `**` wildcard: the runner is the only
thing mounting host directories here, and a narrow allowlist keeps a
workflow from mounting arbitrary host paths into a job container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 16:54:16 +02:00
sjgandClaude Opus 5 26b00608b2 [chore](trx-rs): force-pull the SDK image on the CI runner
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 28s
CI / reuse (pull_request) Successful in 3s
The workflow references the SDK image by the moving `:latest` tag, and
act_runner skips the pull when a local copy of that tag already exists:
the job log reports `docker pull ... forcePull=false` followed by
`Image exists? true`.  Pushing a rebuilt image therefore changes nothing
until someone pulls on the VM by hand, and the run fails as though the
image never gained the tool that was added to the Containerfile —
`sccache` resolving as "No such file or directory" while the pinned
toolchain from an earlier build of the same tag resolves fine.

Set `force_pull: true` so a pushed image is what actually runs, and
document the manual refresh for runners configured before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 16:49:46 +02:00
sjgandClaude Opus 5 c2455bb08c [chore](trx-rs): build the SDK image natively on x86_64 and arm64
CI / reuse (pull_request) Successful in 3s
CI / lint (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
The sccache release asset is per-architecture and the Containerfile
hardcoded the x86_64 triple, so an arm64 build produced an image whose
sccache binary could not execute.  Everything else in the image — the
Debian base, the build dependencies, Node.js and rustup — already
resolves per architecture, so that one URL was what pinned the image to
amd64 and forced Rosetta or qemu on Apple Silicon.

Resolve the triple from `uname -m`, which reflects the build platform
under plain docker/podman build as well as buildx, unlike the
BuildKit-only TARGETARCH.

Document publishing `:latest` as a manifest list built natively on a host
of each architecture, since a single-architecture tag sends the other
side back to emulation, and note that Apple's `container` CLI needs
Rosetta for its BuildKit helper VM regardless of the target.

Pick the act_runner download by architecture for the same reason.

Verified on arm64: the case arm selects
sccache-v0.8.2-aarch64-unknown-linux-musl, and the installed binary
reports `sccache 0.8.2` running natively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 12:14:44 +02:00
sjgandClaude Opus 5 22ff1349f3 [chore](trx-rs): run the frontend job in the SDK image
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 27s
CI / reuse (pull_request) Successful in 5s
The frontend job was added while CI still targeted host-executor runners,
so it never gained the `container:` key the lint and test jobs use.  On
the Docker executor it lands on a bare job container and fails the same
way the Rust jobs did before this branch: `npm` is missing, the Chromium
install shells out to `sudo apt-get`, and `npm run verify-generated`
regenerates the Rust wire contracts, so it needs `cargo` too.

Run it in the SDK image, which already ships Node.js, Chromium at the
path the browser smoke test defaults to, and the pinned Rust toolchain.
Installing Chromium per run is then redundant.

Drop the job's trailing `reuse lint`.  The SDK image deliberately carries
nothing REUSE-related, and the separate `reuse` job lints the whole
repository with the upstream action, generated assets included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:33:02 +02:00
sjg 8b72573521 [chore](trx-rs): add sccache compilation cache
Bake sccache into the SDK image and enable it via RUSTC_WRAPPER in CI and
the devcontainer (not repo-wide, so non-SDK builds are unaffected).

- container/Containerfile: install the sccache musl binary.
- ci.yml: RUSTC_WRAPPER=sccache, CARGO_INCREMENTAL=0, SCCACHE_DIR=/sccache,
  cache size cap, plus a `sccache --show-stats` step per job.
- runner-config.example.yaml: bind-mount /var/cache/sccache into job
  containers so the cache persists across runs and is shared between jobs.
- .devcontainer: enable sccache with a named cache volume.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg f64032dcbe [chore](trx-rs): add OpenRC service for act_runner on Alpine
The runner host is Alpine (OpenRC, no systemd). Add an OpenRC init script
for act_runner (supervise-daemon, depends on docker) plus a conf.d
example for running one instance per project, and rewrite the runner
section of the README with Alpine setup steps (apk docker, dedicated user
in the docker group, register, service install).

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg 8fd1761688 [chore](trx-rs): use nested SDK image path trx-rs/sdk
Match the image name that was pushed to the registry
(git.haxx.space/sjg/trx-rs/sdk) across the workflow, devcontainer and
README.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjg 7022f20b76 [chore](trx-rs): shared SDK image for CI and developers
Rework container/ from a host-executor act_runner image into a single
"SDK" build image used everywhere: as the CI job container (Docker
executor) and by developers locally / via .devcontainer. It bakes in a
pinned Rust toolchain and all build dependencies, so CI and every
developer share the exact same rustc/clippy.

- container/Containerfile: SDK image (Debian + deps + pinned Rust + Node).
- rust-toolchain.toml: pin the toolchain to match the image; also ends the
  "CI clippy newer than local" version skew.
- .gitea/workflows/ci.yml: lint/test run inside the SDK image via
  `container:`; reuse returns to fsfe/reuse-action (Docker executor runs
  it as a sibling container, so nothing REUSE-related is baked in).
- .devcontainer/devcontainer.json: dev use of the same image.
- container/runner-config.example.yaml: Docker-executor runner config for
  the CI VM, capped for a 2-thread budget.
- Drop the obsolete host-executor entrypoint/config/Quadlet units.

Assisted-By: Claude Code (claude-opus-4)
Claude-Session: https://claude.ai/code/session_01NFpGtGTWUEYXLwZeZs2RAV
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:27:42 +02:00
sjgandClaude Opus 5 0d4c657b97 [fix](trx-frontend-http): route feature bundles through the host contract
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s
The bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries.  app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.

Restore the broken behavior:

- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
  undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
  postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
  self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
  fell back to role-only.
- vchan read fifteen application values and services as undefined:
  mode and bandwidth sync, the out-of-band hint, RX audio restart, and
  the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
  original exactly as it did for setRigFrequency, so leaving a channel
  never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
  audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
  decode bar timestamps rendered empty.

Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes.  trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need.  vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.

Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.

Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries.  Covers scheduler self-initialization and the
distance path that the bare window reads broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:20:17 +02:00
sjgandClaude Opus 5 23dbcac5b6 [fix](trx-frontend-http): restore bookmark host contract
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 42s
CI / reuse (pull_request) Failing after 0s
The TypeScript migration turned app.js from a classic script into an ES
module, so its top-level declarations stopped being shared globals.
bookmarks.ts was converted verbatim and kept reading them as window
properties, which app.ts no longer publishes.

Every bookmark interaction read undefined: the Add Bookmark and Select
All buttons stayed hidden because the auth check saw no authEnabled or
authRole, per-rig scopes were missing from the scope picker and the move
target, decoder checkboxes were never built, and Tune threw on
bridge.postPath before issuing a single request.

Extend the typed window.trx host contract instead of restoring globals,
as docs/frontend-architecture.md closes the standalone window property
list.  trx.state publishes authEnabled; trx.core publishes
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, and onDecoderRegistryReady.

Replace the vchan setRigFrequency wrapper with an interceptFrequency
service method, matching interceptMode and interceptBandwidth.  The
wrapper captured an undefined original and silently dropped every tune;
routing interception through setRigFrequency also restores virtual
channel redirection for the application's own tuning.

Read registry-built elements through bmOptionalEl, since bmEl throws and
the decoder checkboxes and decode toggle buttons are legitimately absent
until the registry arrives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 10:15:56 +02:00
90 changed files with 4815 additions and 1457 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"name": "trx-rs SDK",
"image": "git.haxx.space/sjg/trx-rs/sdk:latest",
"workspaceFolder": "/work",
"workspaceMount": "source=${localWorkspaceFolder},target=/work,type=bind",
"mounts": [
"source=trx-rs-sccache,target=/sccache,type=volume"
],
"containerEnv": {
"RUSTC_WRAPPER": "sccache",
"CARGO_INCREMENTAL": "0",
"SCCACHE_DIR": "/sccache"
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"
]
}
}
}
+24 -14
View File
@@ -2,10 +2,11 @@
#
# SPDX-License-Identifier: GPL-2.0-or-later
# CI for the self-hosted, host-executor Podman runners (see container/).
# The runner image bakes in the Rust toolchain and all build dependencies,
# so jobs go straight to cargo — no apt/rustup setup steps (which also
# collided on the dpkg lock when jobs ran concurrently in the same runner).
# CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
# inside the shared trx-rs SDK image (container/Containerfile), which bakes in
# the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
# reuse job uses the upstream Docker action, which the Docker executor launches
# as a sibling container.
name: CI
@@ -16,28 +17,44 @@ on:
env:
CARGO_TERM_COLOR: always
# sccache: shared compilation cache persisted on the runner host (see the
# -v mount in runner-config.example.yaml). CARGO_INCREMENTAL=0 because
# sccache cannot cache incremental artifacts.
RUSTC_WRAPPER: sccache
CARGO_INCREMENTAL: "0"
SCCACHE_DIR: /sccache
SCCACHE_CACHE_SIZE: "20G"
jobs:
lint:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
- name: rustfmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: sccache stats
if: always()
run: sccache --show-stats
test:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --workspace --all-targets --locked
- name: Test
run: cargo test --workspace --locked
- name: sccache stats
if: always()
run: sccache --show-stats
frontend:
runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
defaults:
run:
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
@@ -58,22 +75,15 @@ jobs:
run: npm run lint
- name: Test
run: npm test
- name: Install browser smoke dependency
run: command -v chromium >/dev/null || (sudo apt-get update && sudo apt-get install -y --no-install-recommends chromium)
# Chromium comes from the SDK image at the path the smoke test defaults
# to, so there is nothing to install here.
- name: Browser smoke test
run: npm run test:browser
- name: Verify generated assets
run: npm run verify-generated
- name: Verify generated-file licensing
working-directory: .
run: reuse lint
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
# `reuse` CLI instead of fsfe/reuse-action: the latter is a Docker
# action, which the host-executor runners cannot run. `reuse` is baked
# into the runner image (see container/Containerfile).
run: reuse lint
- uses: fsfe/reuse-action@v5
+434
View File
@@ -0,0 +1,434 @@
APRS symbol set (https://github.com/hessu/aprs-symbols)
=========================================================
Verbatim copy of the upstream COPYRIGHT.md, retrieved 2026-08-03 from
https://github.com/hessu/aprs-symbols. The set has no single SPDX license:
individual symbols carry different terms, summarized below. Attribution
requirement from the upstream README: "If you use this symbol set, please
provide a pointer to the source (http://github.com/hessu/aprs-symbols/)."
---
Copyright and licensing information
======================================
This is a collection of vectorized symbols for use on the APRS system.
The copyright status of this collection is a bit complicated, since the
symbols come from various sources, each having different copyright owners.
Most of the vectorized symbols are loosely based on the low-resolution
"standard" bitmap symbol set as distributed by Stephen Smith, WA8LMF. That
set is used by most APRS software around the world. The low resolution of
those symbols does not allow direct vector conversion, so I've drawn new
symbols in a similar layout. The vector versions try to mimic the original
appearance and colours, with the intention of keeping the set recognizable
and familiar to existing users. In some cases the vector versions are
probably similar enough to the originals, so that they cannot be considered
"original work" by myself. In some of these cases, the originals are
probably also mimicking someone else's design.
The original symbols do not come with any information on their licensing.
They've been distributed with a lot of APRS software over time, but I don't
know who designed which symbol originally. Most likely all of them are
drawn by one of:
* Roger Barker, G4IDE, "original set provided with UI-View" (SK)
* Steve Dimse, KH4G, "U.S. customary set"
* Stephen Smith, WA8LMF
The Adobe Illustrator (.ai) file contains a copy of the original bitmaps
as a hidden layer, just for reference.
Some symbols I obtained from other sources, such as Wikipedia. In those
cases I picked SVG versions which allow commercial reuse (source known, and
the work is placed on public domain, or with a CC license which allows
adaptation and commercial reuse).
Some symbols are vectorized versions of product or brand logos. The
copyright of those is owned by the respective companies (Apple, Microsoft,
Kenwood), and each of those may have some opinions on how the logos are
used. Please check for yourself if you can use them or not.
In the list below I try to summarize the licensing status for each symbol.
Shorthand notation for common licensing status
-------------------------------------------------
* *VEC-OH7LZB* - Vectorized by OH7LZB, based on original APRS symbol set
* Source of original bitmap: http://wa8lmf.net/aprs/APRS_symbols.htm
* Original designer of individual symbol unknown at this time, but one of:
* Roger Barker, G4IDE
* Steve Dimse, KH4G
* Stephen Smith, WA8LMF
* Vectorized versions are designed to look similar
* Licensing: Unknown
* *OH7LZB* - Original vector design by Heikki Hannikainen, OH7LZB
* Different enough (by author's opinion) to make it a new original work,
instead of a copy of the old symbol
* License: CC BY-SA 2.0
* https://creativecommons.org/licenses/by-sa/2.0/
Primary table
----------------
* /! - Police station
* VEC-OH7LZB
* /# - Digipeater / Green star with D in middle
* VEC-OH7LZB
* /$ - Telephone
* VEC-OH7LZB
* /% - DX cluster
* VEC-OH7LZB
* /& - HF gateway
* VEC-OH7LZB
* /' - Small aircraft
* https://openclipart.org/detail/27182/topdown-airplane-view
* Author: Wirelizard (Brian Burger)
* With color and some other small tuning added by OH7LZB
* PD: https://openclipart.org/share
* /( - Mobile satellite station
* OH7LZB
* /) - Wheelchair, handicapped
* PD wheelchair symbol
* Vectorized from bitmap by OH7LZB
* /* - Snowmobile
* https://openclipart.org/detail/15849/snowmobile
* Author: Mystica (https://openclipart.org/user-detail/mystica)
* PD: https://openclipart.org/share
* /+ - Red Cross
* VEC-OH7LZB
* /, - Boy Scouts
* VEC-OH7LZB
* /- - House
* VEC-OH7LZB
* /. - Red X
* VEC-OH7LZB
* // - Red dot
* VEC-OH7LZB
* /0 to /9 - Numbered circles
* VEC-OH7LZB
* Fire
* http://commons.wikimedia.org/wiki/File:FireIcon.svg
* Author: Piotr Jaworski
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* Tent
* https://openclipart.org/detail/174933/green-tent-by-stamps-174933
* Author: stamps
* PD: https://openclipart.org/share
* Motorcycle
* http://commons.wikimedia.org/wiki/File:MUTCD_W8-15P.svg
* This file is in the public domain because it comes from the Manual on
Uniform Traffic Control Devices, sign number W8-15P, which states
specifically on page I-1 that: Any traffic control device design or
application provision contained in this Manual shall be considered to
be in the public domain. Traffic control devices contained in this
Manual shall not be protected by a patent, trademark, or copyright,
except for the Interstate Shield and any other items owned by FHWA.
* Colour version by OH7LZB
* /= - Railroad engine
* http://commons.wikimedia.org/wiki/File:Icon_train.svg
* Author: http://en.wikipedia.org/wiki/User:Richtom80
* CC-BY-SA-2.5,2.0,1.0
* /> - Car
* OH7LZB
* /? - File server
* https://openclipart.org/detail/163717/file-server-by-lyte
* Author: lyte
* PD: https://openclipart.org/share
* /@ - Hurricane predicted path
* VEC-OH7LZB
* /A - Aid station
* VEC-OH7LZB
* Mail (BBS)
* https://openclipart.org/detail/29268/yellow-mail-by-rg1024-29268
* Author: rg1024
* PD: https://openclipart.org/share
* /C - Canoe
* https://openclipart.org/detail/179047/red-canoe-by-rambo-tribble-179047
* https://openclipart.org/detail/179041/canoe-paddle-by-rambo-tribble-179041
* Author: Rambo Tribble
* PD: I, the copyright holder of this work, release this work into the public domain. This applies worldwide.
* /E - Eyeball
* http://commons.wikimedia.org/wiki/File:Blue_eye.svg
* PD: "This file is from the Open Clip Art Library, which released it explicitly into the public domain"
* PD: https://openclipart.org/share
* /F - Tractor
* https://openclipart.org/detail/191654/farm-tractor-by-tmjbeary-191654
* Author: tmjbeary
* PD: https://openclipart.org/share
* /G - Grid square, 3 by 3
* VEC-OH7LZB
* /H - Hotel
* VEC-OH7LZB
* /I - TCP/IP
* VEC-OH7LZB
* /K - School
* OH7LZB
* /L - PC user
* OH7LZB
* /M - Mac apple
* Apple
* /N - NTS
* VEC-OH7LZB
* /O - Hot air balloon
* OH7LZB
* /P - Police
* OH7LZB
* /R - RV
* OH7LZB
* /S - Space Shuttle
* https://openclipart.org/detail/814/space-shuttle-by-johnny_automatic
* PD: Published by the NASA, in "The Brain in Space"
* /T - SSTV
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /U - Bus
* OH7LZB
* /V - ATV, amateur television
* https://openclipart.org/detail/48997/flat-screen-by-rg1024
* Author: rg1024
* Adjusted by OH7LZB
* PD: https://openclipart.org/share
* /W - Wx, Weather service site
* VEC-OH7LZB
* /X - Helicopter
* OH7LZB
* /Y - Sailboat
* OH7LZB
* /Z - Windows flag
* Microsoft
* /[ - Human
* VEC-OH7LZB
* /\ - DF triangle
* VEC-OH7LZB
* /] - Mailbox, post office, letter
* /^ - Large aircraft
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* /_ - Weather station
* VEC-OH7LZB
* /` - Satellite dish
* OH7LZB
* /a - Ambulance
* OH7LZB
* /b - Bicycle
* http://commons.wikimedia.org/wiki/File:Bicycle_evolution-numbers.svg
* Author: Wikipedia user: Al2
* CC BY 3.0
* /c - Incident command post
* VEC-OH7LZB
* /d - Fire station
* VEC-OH7LZB
* /e - Horse, equestrian
* https://openclipart.org/detail/142627/horse-riding-lesson-by-olku
* Author: OlKu
* PD: https://openclipart.org/share
* /f - Fire truck
* OH7LZB
* /g - Hang glider
* OH7LZB
* /h - Hospital
* VEC-OH7LZB
* /i - IOTA, islands on the air
* http://commons.wikimedia.org/wiki/File:Palm_Island_R.svg
* PI
* /j - Jeep
* OH7LZB
* /k - Truck
* OH7LZB
* /l - Laptop
* OH7LZB
* /m - Mic-E repeater
* VEC-OH7LZB
* /n - Node, black bulls-eye
* VEC-OH7LZB
* /o - Emergency operations center
* VEC-OH7LZB
* /p - Dog(e)
* OH7LZB
* /q - Grid square, 2 by 2
* VEC-OH7LZB
* /r - Repeater tower
* OH7LZB
* /s - Ship, power boat
* OH7LZB
* /t - Truck stop
* VEC-OH7LZB
* /u - Semi-trailer truck, 18-wheeler
* OH7LZB
* /v - Van
* OH7LZB
* /w - Water station
* VEC-OH7LZB
* /x - X / Unix
* https://commons.wikimedia.org/wiki/File:X11.svg
* PD
* /y - House, yagi antenna
* VEC-OH7LZB
* /z - Shelter
* VEC-OH7LZB
Secondary table
------------------
* Emergency
* VEC-OH7LZB
* Numbered digipeater / Green star
* VEC-OH7LZB
* Bank
* VEC-OH7LZB
* Numbered gateway / Black diamond
* VEC-OH7LZB
* Crash site
* OH7LZB
* Cloudy
* OH7LZB
* MEO
* VEC-OH7LZB
* Snowflake
* http://commons.wikimedia.org/wiki/File:Snowflake_01.svg
* Author: Wikipedia user: Amada44
* Public Domain
* Church
* VEC-OH7LZB
* Girl Scout
* VEC-OH7LZB
* Looks slightly like the common USA girl scouts logos. Should be different
enough to not infringe on "Girl Scouts of the USA" copyrights.
* Home (HF antenna)
* VEC-OH7LZB
* Unknown position
* VEC-OH7LZB
* Destination
* VEC-OH7LZB
* Numbered circle
* VEC-OH7LZB
* Petrol Station
* OH7LZB
* Hail
* VEC-OH7LZB
* Park
* VEC-OH7LZB
* Gale Flag
* VEC-OH7LZB
* Red car from above
* OH7LZB
* Info Kiosk
* VEC-OH7LZB
* Hurricane
* OH7LZB
* Numbered white box
* VEC-OH7LZB
* Snow blowing
* VEC-OH7LZB
* Coast Guard
* VEC-OH7LZB
* Drizzle
* VEC-OH7LZB
* Smoke / Chimney
* VEC-OH7LZB
* Freezing rain
* VEC-OH7LZB
* Snow Shwr
* VEC-OH7LZB
* Haze
* VEC-OH7LZB
* Rain Shower
* VEC-OH7LZB
* Lightning
* OH7LZB
* "Kenwood radio"
* Kenwood logo, vectorized
* "Lighthouse"
* CC BY-SA 2.0
* http://wiki.openstreetmap.org/wiki/File:Lighthouse.svg
* Nav Buoy
* OH7LZB
* Rocket
* http://www.clker.com/clipart-gglkuglug.html
* PD according to clker.com license
* Parking
* VEC-OH7LZB
* Earthquake, Restaurant
* VEC-OH7LZB
* Satellite
* OH7LZB
* Thunderstorm
* OH7LZB
* Sunny
* OH7LZB
* VORTAC, Numbered WXS
* VEC-OH7LZB
* Pharmacy Rx
* OH7LZB
* Wall Cloud
* OH7LZB
* Numbered plane
* https://openclipart.org/detail/183204/plane-red-by-sketchartist-183204
* Author: SketchArtist
* PD: https://openclipart.org/share
* Numbered WX Station
* VEC-OH7LZB
* Rain
* Source: http://commons.wikimedia.org/wiki/File:Heavy-rain-shower-transparent.svg
* Author: Wikipedia user: Peepo
* Public Domain
* With modifications by OH7LZB
* Numbered diamond
* VEC-OH7LZB
* Dust blowing
* NA
* Numbered civil defence
* VEC-OH7LZB
* DX spot
* VEC-OH7LZB
* Sleet
* NA
* Funnel Cloud
* NA
* Gale
* VEC-OH7LZB
* Store
* https://openclipart.org/detail/89299/cart-medium-by-martins.bruvelis
* Author: martins.bruvelis
* Public Domain
* Adjustments by OH7LZB
* Numbered black box
* VEC-OH7LZB
* Work zone / Excavator
* Based on http://www.clker.com/clipart-292480.html PNG version
* Vectorized and colors adjusted by OH7LZB
* PD according to clker.com documentation, uploader KURSVEIAL
* SUV
* OH7LZB
* Milepost, Numbered triangle, Circle sm
* VEC-OH7LZB
* Partly cloudy
* OH7LZB
* Restrooms, Numbered boat
* VEC-OH7LZB
* Tornado (also used in Funnel cloud, Skywarn)
* https://openclipart.org/detail/104887/tornado-by-laabadon
* Author: Laabadon
* Public Domain
* Numbered truck
* OH7LZB
* Numbered van
* OH7LZB
* Flooding
* NA
* Sky warn, Numbered shelter, fog
* VEC-OH7LZB
+4 -1
View File
@@ -144,4 +144,7 @@ a unified set of frontends.
GPL-2.0-or-later. See [`LICENSES`](LICENSES) for the full license text and
bundled third-party license files. Bundled third-party components retain their
original licenses: Leaflet is BSD-2-Clause, DSEG is OFL-1.1, and opus-decoder
is MIT.
is MIT. The APRS symbol sprites come from
[hessu/aprs-symbols](https://github.com/hessu/aprs-symbols); their per-symbol
copyright status is catalogued in
[`LICENSES/LicenseRef-APRS-Symbols.txt`](LICENSES/LicenseRef-APRS-Symbols.txt).
+18
View File
@@ -12,6 +12,8 @@ path = [
"trx-rs.toml.example",
"docs/**",
"aidocs/**",
"container/**",
".devcontainer/**",
"src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png",
@@ -54,3 +56,19 @@ SPDX-License-Identifier = "OFL-1.1"
path = ["src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/opus-decoder-0.7.11.min.js"]
SPDX-FileCopyrightText = "2021-2025 Ethan Halsall"
SPDX-License-Identifier = "MIT"
# Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols), rev H.
# The set has no single upstream license -- individual symbols carry different
# terms, catalogued in LICENSES/LicenseRef-APRS-Symbols.txt. Upstream asks that
# users point back to https://github.com/hessu/aprs-symbols/.
[[annotations]]
path = [
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-0-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-1-2x.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/vendor/aprs-symbols-24-2-2x.png",
]
SPDX-FileCopyrightText = "Heikki Hannikainen OH7LZB and the APRS symbol set authors (https://github.com/hessu/aprs-symbols)"
SPDX-License-Identifier = "LicenseRef-APRS-Symbols"
+45 -38
View File
@@ -2,60 +2,67 @@
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Gitea Actions runner image for trx-rs CI (host-executor / "Pattern B").
# trx-rs SDK / build image.
#
# All build dependencies, the Rust toolchain, Node.js (for JS actions such as
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI
# runs skip the per-run apt/rustup install cost. `sudo` is present so the
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they
# just become fast no-ops because everything is already installed.
# Single source of truth for the build environment. Used two ways:
# * CI — as the job container for the lint/test jobs (Docker executor).
# * Dev — run locally or via .devcontainer for a reproducible toolchain.
#
# Pinning the Rust version here (and in rust-toolchain.toml) means CI and every
# developer share the exact same rustc/clippy, so "works locally, fails in CI"
# cannot happen.
FROM docker.io/library/debian:bookworm-slim
ARG ACT_RUNNER_VERSION=0.2.11
# Keep in sync with rust-toolchain.toml.
ARG RUST_VERSION=1.97.1
ARG NODE_MAJOR=20
ENV DEBIAN_FRONTEND=noninteractive \
RUSTUP_HOME=/opt/rustup \
CARGO_HOME=/opt/cargo \
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin
RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
PATH=/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin
# Base tooling + trx-rs build dependencies (mirrors .gitea/workflows/ci.yml).
# Build dependencies (mirror README's manual instructions).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl xz-utils git sudo pipx \
ca-certificates curl git \
build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev chromium \
&& rm -rf /var/lib/apt/lists/*
# Node.js (JS-based actions need node in PATH under the host executor).
# Node.js JS-based actions (actions/checkout, actions/cache) run *inside*
# the job container under the Docker executor, so node must be present.
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml).
# The [charset-normalizer] extra provides an encoding-detection backend;
# without it (and without libmagic) reuse fails to import at runtime.
RUN PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install 'reuse[charset-normalizer]'
# Rust stable with rustfmt + clippy, installed system-wide.
# Pinned Rust toolchain, installed world-readable so any UID the runner or a
# devcontainer uses can invoke cargo.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \
--component rustfmt --component clippy \
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME"
| sh -s -- -y --no-modify-path \
--default-toolchain "${RUST_VERSION}" --profile minimal \
--component rustfmt --component clippy \
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
# act_runner binary.
RUN arch="$(dpkg --print-architecture)"; \
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \
curl -fsSL -o /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-${rarch}" \
&& chmod +x /usr/local/bin/act_runner
# sccache — shared compilation cache. Enabled at build time via
# RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
# non-SDK builds are unaffected. musl build is static and runs anywhere.
#
# The release asset is per-architecture, so resolve it from `uname -m` rather
# than hardcoding one triple: everything else in this image is arch-agnostic,
# and a pinned x86_64 URL is what forces an amd64 build (and Rosetta or qemu)
# on an arm64 host. `uname -m` reflects the build platform under plain
# docker/podman build as well as buildx, unlike the BuildKit-only TARGETARCH.
ARG SCCACHE_VERSION=0.8.2
RUN set -eux; \
case "$(uname -m)" in \
x86_64) sccache_arch=x86_64 ;; \
aarch64|arm64) sccache_arch=aarch64 ;; \
*) echo "unsupported architecture for sccache: $(uname -m)" >&2; exit 1 ;; \
esac; \
sccache_dist="sccache-v${SCCACHE_VERSION}-${sccache_arch}-unknown-linux-musl"; \
curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/${sccache_dist}.tar.gz" \
| tar -xz -C /tmp; \
install -m755 "/tmp/${sccache_dist}/sccache" /usr/local/bin/sccache; \
rm -rf /tmp/sccache-*
# Default config template (seeded into the /data volume on first boot).
COPY config.yaml /etc/act_runner/config.yaml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /data holds the .runner registration, cache and workflow workspaces.
VOLUME /data
WORKDIR /data
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
WORKDIR /work
+156 -81
View File
@@ -3,116 +3,191 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Podman-based Gitea Actions runners
# trx-rs SDK image
Run two independent Gitea Actions runners on one host as rootless Podman
containers managed by systemd (Quadlet) — one per project — instead of two
VMs. Uses the **host executor**: workflow steps run directly inside a
purpose-built runner image that already has the Rust toolchain and all build
dependencies baked in, so CI runs skip the per-run install cost and no
Docker/Podman socket is needed.
## Files
A single container image that is the canonical build environment for trx-rs,
used **both** by CI and by developers. It bakes in the pinned Rust toolchain
(matching `rust-toolchain.toml`) and every build dependency, so the compiler
and `clippy` are identical everywhere — no "works on my machine".
| File | Purpose |
|------|---------|
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. |
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. |
| `config.yaml` | act_runner config template (seeded into each runner's volume). |
| `trx-rs-runner.container` | Quadlet unit for the trx-rs runner. |
| `project2-runner.container` | Quadlet unit for the second project's runner. |
| `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
| `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
## Prerequisites (once per host)
## Build and publish
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged
user that will own the runners:
Nothing in the image is architecture-specific: the base image, the Debian build
dependencies, Node.js, `rustup` and the `sccache` release all resolve per
architecture, so the same `Containerfile` builds natively on x86_64 and arm64.
Single architecture — the tag then only works on the architecture you built it
on:
```bash
# Survive logout / start on boot without an interactive session.
loginctl enable-linger "$USER"
# from the repo root
podman build -t git.haxx.space/sjg/trx-rs/sdk:latest container
podman login git.haxx.space
podman push git.haxx.space/sjg/trx-rs/sdk:latest
```
No `podman.socket` is required for the host executor.
## 1. Build the image
**Both architectures without emulation.** The CI runner is x86_64 and Apple
Silicon developer machines are arm64, so `:latest` has to be a manifest list —
a single-architecture tag makes the other side fall back to Rosetta or qemu.
Build each half natively on a host of that architecture, then join them:
```bash
cd container
podman build -t trx-rs-ci:latest .
# on an x86_64 host
podman build --platform linux/amd64 -t git.haxx.space/sjg/trx-rs/sdk:latest-amd64 container
podman push git.haxx.space/sjg/trx-rs/sdk:latest-amd64
# on an arm64 host
podman build --platform linux/arm64 -t git.haxx.space/sjg/trx-rs/sdk:latest-arm64 container
podman push git.haxx.space/sjg/trx-rs/sdk:latest-arm64
# from either, once both are pushed
podman manifest create git.haxx.space/sjg/trx-rs/sdk:latest \
git.haxx.space/sjg/trx-rs/sdk:latest-amd64 \
git.haxx.space/sjg/trx-rs/sdk:latest-arm64
podman manifest push --all git.haxx.space/sjg/trx-rs/sdk:latest
```
## 2. Get a registration token
Building both from one machine is a single command
(`podman build --platform linux/amd64,linux/arm64 --manifest ...`), but the
foreign half runs under emulation and is slow — the two-host flow above is
what keeps every build native.
For **each** repo: *Settings → Actions → Runners → Create new Runner* and copy
the token. (Org- or instance-level tokens work too if you prefer wider scope.)
Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
Make the package **public** (Gitea → Packages → the image → Settings) so the CI
runner and developers can pull it without credentials. If you keep it private,
add `credentials:` under the workflow's `container:` and log the runner into the
registry.
## 3. Install and start the runners
Pushing a rebuilt image is not enough on its own: `:latest` is a moving tag, and
act_runner reuses whatever it cached the first time unless `force_pull: true` is
set (see `runner-config.example.yaml`). Without it the job log says
`Image exists? true` and the run behaves as though the image were never
rebuilt — a tool added to the `Containerfile` reads as missing from the image.
Either set `force_pull`, or refresh the VM's copy by hand:
```bash
mkdir -p ~/.config/containers/systemd
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/
# Paste each repo's token for the FIRST boot only:
# Environment=GITEA_RUNNER_REGISTRATION_TOKEN=xxxx…
$EDITOR ~/.config/containers/systemd/trx-rs-runner.container
$EDITOR ~/.config/containers/systemd/project2-runner.container
systemctl --user daemon-reload
systemctl --user start trx-rs-runner
systemctl --user start project2-runner
systemctl --user status trx-rs-runner
podman logs -f gitea-runner-trx-rs
docker pull git.haxx.space/sjg/trx-rs/sdk:latest
docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
```
Once each runner shows **online** in the repo's runner list, blank out the
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
the `…-data` volume) and `systemctl --user daemon-reload`.
### macOS note
## Required workflow change: the `reuse` job
Apple's `container` CLI builds through a BuildKit helper VM that is configured
with Rosetta whether or not the target is x86_64, so `container build` fails
with *"Rosetta is not installed"* on a clean machine. That is a property of the
builder, not of this image — `container run` works natively without it. Either
install Rosetta once (`softwareupdate --install-rosetta`, after which an arm64
build still produces a native arm64 image), or build with Podman, whose arm64
BuildKit needs no emulation.
The host executor runs steps directly in the container and therefore **cannot
run Docker-based actions**. The current `reuse` job uses `fsfe/reuse-action@v5`,
which is a Docker action. `reuse` is baked into the image, so replace that job
with a plain command:
## Developer use
```yaml
reuse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: REUSE compliance
run: reuse lint
Reproducible one-off build, no local toolchain needed:
```bash
podman run --rm -it -v "$PWD":/work -w /work \
git.haxx.space/sjg/trx-rs/sdk:latest \
cargo build --release
```
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup
steps still run, but become fast no-ops because the image already has those
packages and the toolchain. (`sudo` is included in the image for exactly this
reason.)
Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
(`.devcontainer/devcontainer.json` points at the same image).
> If you would rather keep Docker-based actions and per-run images, use the
> **Docker executor** instead: drop the `:host` suffix from the label in
> `config.yaml`, enable `systemctl --user --now enable podman.socket`, mount it
> into the container, and set `container.docker_host` to the socket path. That
> trades the baked-in speed for stronger per-job isolation.
Building outside the container? `rust-toolchain.toml` pins the same rustc, so
`rustup` installs the matching toolchain automatically.
## Tuning
## CI use
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds
are heavy; 12 is sensible when two runners share a host.
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps
so one project cannot starve the other.
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is
enforcing.
`.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
this image via the `container:` key, so they skip all setup and go straight to
`cargo` and `npm`. The frontend job needs three things from the image beyond
Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
browser smoke test, and `cargo``npm run verify-generated` regenerates the
Rust wire contracts before checking for drift.
## Committing these files
The `reuse` job stays on the upstream `fsfe/reuse-action` (a Docker action the
Docker executor launches as a sibling container) — nothing REUSE-related is
baked into the SDK, and it lints the whole repository, so no job runs its own
licence check.
If you add this directory to a REUSE-checked repo, register the markdown in
`REUSE.toml` (the other files carry inline SPDX headers):
## Compilation cache (sccache)
```toml
[[annotations]]
path = ["container/**"]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later"
The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
so plain `cargo` builds outside the SDK are unaffected).
- **CI** persists the cache on the runner host — create the dir once:
`mkdir -p /var/cache/sccache`. It is bind-mounted into each job container at
`/sccache` (see `runner-config.example.yaml`), so cache survives across runs
and is shared between the lint/test jobs and both projects.
- **Devcontainer** uses a named volume (`trx-rs-sccache`).
- Check effectiveness with `sccache --show-stats` (the CI jobs print it).
`CARGO_INCREMENTAL=0` is set wherever sccache is on, since sccache cannot cache
incremental artifacts.
## CI runner (Alpine / OpenRC)
The runner uses the **Docker executor** (not the host executor): per-job
container isolation and standard `ubuntu-latest` semantics. `act_runner` runs
as an OpenRC service. Files provided:
| File | Purpose |
|------|---------|
| `act_runner.openrc` | OpenRC init script (`supervise-daemon`, depends on docker). |
| `act_runner.confd.example` | Per-instance `conf.d` settings for multi-runner hosts. |
**Cap the thread budget.** In a VM, pin its vCPUs to specific host threads
(libvirt/KVM):
```xml
<vcpu placement='static'>2</vcpu>
<cputune>
<vcpupin vcpu='0' cpuset='4'/>
<vcpupin vcpu='1' cpuset='5'/>
</cputune>
```
On bare metal, the `container.options: "--cpus=2"` and `capacity: 1` in
`runner-config.example.yaml` already bound each runner.
**Set it up:**
```bash
# 1. Docker + a dedicated user with socket access
apk add docker docker-cli
rc-update add docker default && rc-service docker start
adduser -S -D -H -h /var/lib/act_runner act
addgroup act docker
# 2. act_runner binary (static Go build, works on musl)
# Upstream publishes per-architecture builds; pick the host's.
case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; esac
curl -fsSL -o /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v0.2.11/act_runner-0.2.11-linux-${arch}"
chmod +x /usr/local/bin/act_runner
# 3. Config + register one runner per project (scope keeps their jobs apart)
install -Dm644 container/runner-config.example.yaml /etc/act_runner/trx-rs.yaml
install -d -o act /var/lib/act_runner/trx-rs
su act -s /bin/sh -c 'cd /var/lib/act_runner/trx-rs && \
act_runner register --no-interactive \
--instance https://git.haxx.space --token <TOKEN> \
--name trx-rs-ci \
--labels "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"'
# 4. OpenRC service (repeat the symlink+conf.d for the second project)
install -m755 container/act_runner.openrc /etc/init.d/act_runner
ln -s act_runner /etc/init.d/act_runner.trx-rs
install -m644 container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
rc-update add act_runner.trx-rs default
rc-service act_runner.trx-rs start
```
Check it with `rc-service act_runner.trx-rs status` and
`tail -f /var/log/act_runner.trx-rs.log`.
+14
View File
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Per-instance settings for an act_runner OpenRC service.
# Copy to /etc/conf.d/<service-name>, e.g. /etc/conf.d/act_runner.trx-rs
# (the name must match the /etc/init.d/ symlink).
# User that runs the daemon. Must be a member of the `docker` group.
runner_user="act"
# Per-instance state dir (holds the .runner registration) and config file,
# so two runners on one host stay independent.
runner_dir="/var/lib/act_runner/trx-rs"
runner_config="/etc/act_runner/trx-rs.yaml"
+44
View File
@@ -0,0 +1,44 @@
#!/sbin/openrc-run
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
# SPDX-License-Identifier: GPL-2.0-or-later
#
# OpenRC service for a Gitea act_runner (Docker executor) on Alpine.
#
# Install as /etc/init.d/act_runner (chmod +x). Single instance uses
# /etc/act_runner/config.yaml. For one runner per project, symlink this script
# and add a matching conf.d file:
#
# ln -s act_runner /etc/init.d/act_runner.trx-rs
# cp container/act_runner.confd.example /etc/conf.d/act_runner.trx-rs
# $EDITOR /etc/conf.d/act_runner.trx-rs # set runner_dir / runner_config
# rc-update add act_runner.trx-rs default
# rc-service act_runner.trx-rs start
description="Gitea Actions runner"
: "${runner_user:=act}"
: "${runner_dir:=/var/lib/act_runner}"
: "${runner_config:=/etc/act_runner/config.yaml}"
command="/usr/local/bin/act_runner"
command_args="daemon --config ${runner_config}"
# No group given, so supplementary groups (incl. docker) are initialised.
command_user="${runner_user}"
directory="${runner_dir}"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
pidfile="/run/${RC_SVCNAME}.pid"
output_log="/var/log/${RC_SVCNAME}.log"
error_log="/var/log/${RC_SVCNAME}.log"
depend() {
need docker
use net dns
}
start_pre() {
checkpath -d -m 0750 -o "${runner_user}" "${runner_dir}"
checkpath -f -m 0640 -o "${runner_user}" "${output_log}"
}
-32
View File
@@ -1,32 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# act_runner configuration template. Seeded into /data/config.yaml on first
# boot; edit the copy inside the volume to change settings per runner.
log:
level: info
runner:
# Registration state. Relative to the daemon's working directory (/data).
file: .runner
# Concurrent jobs this runner will pick up. Rust builds are heavy — keep this
# modest, especially if two runners share one host. The trx-rs workflow has
# three parallel jobs (lint, test, reuse); capacity 2 lets two overlap.
capacity: 2
timeout: 3h
# Map the workflow's `runs-on: ubuntu-latest` to the HOST executor, i.e. run
# steps directly inside THIS container (which already has all the toolchain).
# No Docker/Podman socket is required in this mode.
labels:
- "ubuntu-latest:host"
cache:
# Built-in actions cache server (used by actions/cache). Stored in the volume.
enabled: true
dir: "/data/cache"
host:
# Where per-job workspaces are created.
workdir_parent: /data/workflows
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Registers the runner on first boot (if no .runner state exists in /data),
# then runs the act_runner daemon. Idempotent: on subsequent boots it reuses
# the stored registration and ignores the token.
set -euo pipefail
CONFIG_FILE="${CONFIG_FILE:-/data/config.yaml}"
cd /data
# Seed the config from the image's template on first boot so it lives in the
# persistent volume and can be edited there.
if [ ! -f "$CONFIG_FILE" ]; then
cp /etc/act_runner/config.yaml "$CONFIG_FILE"
fi
# runner.file in config.yaml is ".runner" (relative to this CWD => /data/.runner).
if [ ! -f /data/.runner ]; then
if [ -z "${GITEA_RUNNER_REGISTRATION_TOKEN:-}" ]; then
echo "ERROR: no /data/.runner registration and GITEA_RUNNER_REGISTRATION_TOKEN is empty." >&2
echo " Grab a token from the repo's Settings -> Actions -> Runners and set it" >&2
echo " in the Quadlet unit for the first boot only." >&2
exit 1
fi
echo "Registering runner '${GITEA_RUNNER_NAME:-podman}' with ${GITEA_INSTANCE_URL} ..."
act_runner register --no-interactive \
--config "$CONFIG_FILE" \
--instance "${GITEA_INSTANCE_URL:?set GITEA_INSTANCE_URL}" \
--token "$GITEA_RUNNER_REGISTRATION_TOKEN" \
--name "${GITEA_RUNNER_NAME:-podman}" \
--labels "${GITEA_RUNNER_LABELS:-ubuntu-latest:host}"
fi
exec act_runner daemon --config "$CONFIG_FILE"
-40
View File
@@ -1,40 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the SECOND project's Gitea Actions runner,
# co-located on the same host as the trx-rs runner.
#
# It has its own name, its own data volume and its own registration token, so
# the two runners are fully independent. They share the `ubuntu-latest` label,
# but registration SCOPE (which repo each token came from) keeps their jobs
# separate — neither will pick up the other's work.
#
# If project 2 needs different build dependencies, build it its own image from
# an adjusted Containerfile and point Image= at that instead of reusing the
# trx-rs image below.
[Unit]
Description=Gitea Actions runner — project 2
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/gitea-act-runner:latest
ContainerName=gitea-runner-project2
Volume=gitea-runner-project2-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=project2-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
TimeoutStartSec=0
[Install]
WantedBy=default.target
+47
View File
@@ -0,0 +1,47 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Example act_runner config for the Docker-executor runner that lives in the
# CI VM. This is NOT the SDK image — it configures the runner that launches
# per-job containers (including the trx-rs SDK image referenced by the
# workflow's `container:` key). Copy to the VM and pass with
# `act_runner daemon --config`.
log:
level: info
runner:
file: .runner
# One concurrent job. With one runner per project on a 2-vCPU VM this keeps
# total CI usage at ~2 threads.
capacity: 1
timeout: 3h
# Docker executor: no ":host" suffix. Maps runs-on labels to base images
# (the workflow overrides these per job via `container:`).
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-latest"
cache:
enabled: true
container:
# Cap every job container's CPU so CI stays within the 2-thread budget even
# if capacity is raised later. The -v mount persists the sccache cache on the
# host (create it first: `mkdir -p /var/cache/sccache`), matching SCCACHE_DIR
# in the workflow.
options: "--cpus=2 -v /var/cache/sccache:/sccache"
# act_runner rejects every bind mount unless it is listed here — the default
# is an empty allowlist, so the -v above is dropped with only a
# "[...] is not a valid volume, will be ignored" line in the job log, and
# SCCACHE_DIR then points at a directory that does not outlive the job.
valid_volumes:
- /var/cache/sccache
# Reuse the host VM's Docker network for the built-in cache/artifact server.
network: "host"
# The workflow pulls the SDK image by the moving `:latest` tag. Without this
# the runner logs "Image exists? true" and reuses whatever it cached the
# first time, so pushing a rebuilt image has no effect until someone pulls
# on the VM by hand — which looks like the image is missing a tool it in
# fact has. The extra registry round-trip per job is nothing next to a build.
force_pull: true
-41
View File
@@ -1,41 +0,0 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Rootless Podman Quadlet for the trx-rs Gitea Actions runner.
# Install to ~/.config/containers/systemd/trx-rs-runner.container then:
# systemctl --user daemon-reload
# systemctl --user start trx-rs-runner
#
# First boot only: paste a registration token (repo Settings -> Actions ->
# Runners) into GITEA_RUNNER_REGISTRATION_TOKEN. After the runner appears
# online you can blank it again — the registration is persisted in the volume.
[Unit]
Description=Gitea Actions runner — trx-rs
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/trx-rs-ci:latest
ContainerName=gitea-runner-trx-rs
# Persistent state: .runner registration, cache, workspaces.
Volume=gitea-runner-trx-rs-data:/data:Z
Environment=CONFIG_FILE=/data/config.yaml
Environment=GITEA_INSTANCE_URL=https://git.haxx.space
Environment=GITEA_RUNNER_NAME=trx-rs-podman
Environment=GITEA_RUNNER_LABELS=ubuntu-latest:host
Environment=GITEA_RUNNER_REGISTRATION_TOKEN=
# Resource caps so a heavy Rust build here cannot starve the other project's
# runner on the same host. Tune to your box.
PodmanArgs=--cpus=4.0 --memory=6g
[Service]
Restart=always
# A cold Rust build can be slow; don't let systemd consider startup failed.
TimeoutStartSec=0
[Install]
WantedBy=default.target
+9
View File
@@ -39,6 +39,15 @@ entry, so they use three intentional host namespaces:
| `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. |
| `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. |
`frontend/src/plugins/host.ts` declares the typed view of `window.trx.state`
and `window.trx.core` that feature bundles consume. Feature entries import it
instead of re-deriving the contract, so a service that moves out of the
application entry is added in one place. Application state that a feature needs
belongs in `trx.state`, and shared behavior belongs in `trx.core`; a feature
that has to intercept application behavior registers a `modules.*` method the
application calls, as the virtual-channel entry does for tuning, mode,
bandwidth, and frequency display.
The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
+11
View File
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
#
# SPDX-License-Identifier: GPL-2.0-or-later
#
# Pins the Rust toolchain for reproducible builds. Keep in sync with the
# SDK image (container/Containerfile, ARG RUST_VERSION). rustup honours this
# automatically for local builds outside the SDK container.
[toolchain]
channel = "1.97.1"
components = ["rustfmt", "clippy"]
@@ -1,6 +1,11 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/ais.ts
var aisWindow = window;
var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
var aisStatus = document.getElementById("ais-status");
var aisMessagesEl = document.getElementById("ais-messages");
var aisFilterInput = document.getElementById("ais-filter");
@@ -119,10 +124,10 @@ function aisRouteText(msg) {
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
}
function aisDistanceText(msg) {
if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
return "";
}
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon);
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
@@ -303,7 +308,7 @@ document.getElementById("settings-clear-ais-history")?.addEventListener("click",
void (async () => {
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aisWindow.postPath?.("/clear_ais_decode");
await hostCore.postPath("/clear_ais_decode");
resetAisHistoryView();
} catch (e) {
console.error("AIS history clear failed", e);
@@ -637,6 +637,22 @@ function elementById(id) {
};
const layoutCapabilities = { broadcast: false, digital: false };
let activeRigId = null;
const layoutSections = [
{ id: "advanced-radio-controls", key: "advanced" },
{ id: "audio-controls", key: "audio" },
{ id: "scheduler-controls", key: "scheduler" }
];
const seededSections = /* @__PURE__ */ new Set();
let appliedLayoutName = null;
function seedLayoutSections(layout, layoutChanged) {
layoutSections.forEach(({ id, key }) => {
const section = document.getElementById(id);
if (!section) return;
if (!layoutChanged && seededSections.has(id)) return;
seededSections.add(id);
section.open = layout[key];
});
}
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
@@ -690,12 +706,9 @@ function elementById(id) {
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName);
const details = document.getElementById("advanced-radio-controls");
if (details) details.open = layout.advanced;
const audioDetails = document.getElementById("audio-controls");
if (audioDetails) audioDetails.open = layout.audio;
const schedulerDetails = document.getElementById("scheduler-controls");
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
const layoutChanged = appliedLayoutName !== permittedName;
appliedLayoutName = permittedName;
seedLayoutSections(layout, layoutChanged);
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
browserWindow.navigateToTab(layout.preferredTab);
}
@@ -730,10 +743,120 @@ function elementById(id) {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
tray.insertBefore(details, document.getElementById("audio-controls"));
api.applyLayout(savedLayoutName(), { persist: false });
}
}
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function anchorMenu(button, menu) {
const rect = button.getBoundingClientRect();
if (menu.parentElement !== document.body) document.body.appendChild(menu);
menu.style.position = "fixed";
menu.style.zIndex = "9700";
menu.style.top = "0px";
menu.style.bottom = "auto";
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() {
const actions = document.querySelector(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
const wrap = document.createElement("div");
wrap.id = "top-bar-more";
wrap.className = "top-bar-more";
const button = document.createElement("button");
button.type = "button";
button.id = "top-bar-more-btn";
button.className = "header-bar-btn top-bar-more-btn";
button.textContent = "⋯";
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "More controls");
button.title = "More controls";
const menu = document.createElement("div");
menu.id = "top-bar-more-menu";
menu.className = "top-bar-more-menu";
menu.setAttribute("role", "menu");
button.setAttribute("aria-controls", menu.id);
wrap.append(button, menu);
actions.appendChild(wrap);
const closeMenu = () => {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node)) {
closeMenu();
return;
}
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu();
});
const barFits = () => {
const bar = actions.closest(".tab-bar");
const nav = bar?.querySelector(".tab-bar-nav");
if (!bar || !nav) return true;
const barRect = bar.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.right > barRect.right + 1) return false;
const tabs = Array.from(nav.querySelectorAll(".tab")).filter((tab) => tab.offsetParent !== null);
if (!tabs.length) return true;
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
return tabsRight <= actionsRect.left - 1;
};
const reflowOverflow = () => {
const nav = document.querySelector(".tab-bar-nav");
const bar = actions.closest(".tab-bar");
nav?.classList.remove("nav-icons-only");
bar?.classList.remove("bar-tight");
overflowOrder.forEach((selector) => {
const element = menu.querySelector(selector);
if (element) actions.insertBefore(element, wrap);
});
wrap.hidden = true;
for (const selector of overflowOrder) {
if (barFits()) break;
const element = actions.querySelector(selector);
if (!element) continue;
wrap.hidden = false;
menu.appendChild(element);
}
if (nav && !barFits()) nav.classList.add("nav-icons-only");
if (bar && !barFits()) bar.classList.add("bar-tight");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
if (typeof ResizeObserver !== "undefined") {
const bar = actions.closest(".tab-bar");
const observer = new ResizeObserver(() => {
reflowOverflow();
});
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => {
reflowOverflow();
}).catch(() => {
});
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -741,7 +864,8 @@ function elementById(id) {
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
more.setAttribute("aria-label", "Tools and settings");
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
@@ -773,7 +897,10 @@ function elementById(id) {
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus();
if (open) {
anchorMenu(more, menu);
menu.querySelector('[role="menuitem"]')?.focus();
}
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
@@ -840,6 +967,7 @@ function elementById(id) {
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installTopBarOverflow();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
@@ -1726,6 +1854,7 @@ function hideAuthGate() {
});
navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true });
syncTopBarAccess();
if (!bandplanData) void loadBandplanJson();
}
function showAuthError(msg) {
const el = requiredElement("auth-error");
@@ -1934,7 +2063,6 @@ var signalSplitValueEl = document.getElementById("signal-split-value");
var overviewPeakHoldEl = document.getElementById("overview-peak-hold");
var themeToggleBtn = document.getElementById("theme-toggle");
var headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
var headerRigSummary = document.getElementById("header-rig-summary");
var headerStylePickSelect = document.getElementById("header-style-pick-select");
var rdsPsOverlay = document.getElementById("rds-ps-overlay");
var tabMainEl = document.getElementById("tab-main");
@@ -2131,6 +2259,7 @@ function formatSigStrength(dbm) {
return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`;
}
function refreshSigStrengthDisplay() {
renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -2184,6 +2313,7 @@ async function restorePreviousTuneState() {
savePreviousTuneState();
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -2618,6 +2748,17 @@ if (headerStylePickSelect) {
function readyText() {
return lastClientCount !== null ? `Ready · ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
var HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
var HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg) {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -2651,19 +2792,6 @@ function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
}
selectEl.disabled = disabled;
}
function updateRigIdentitySummary(rigId, pending = false) {
if (!headerRigSummary) return;
const rig = serverRigs.find((entry) => entry?.remote === rigId);
if (!rig) {
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
return;
}
const hardware = [rig.manufacturer, rig.model].map((value) => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
}
function updateRigSubtitle(activeRigId) {
if (!rigSubtitle) return;
const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--";
@@ -2694,7 +2822,6 @@ function applyRigList(activeRigId, rigIds, displayNames = {}) {
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
@@ -2745,12 +2872,12 @@ function refreshOperatorLayoutCapabilities() {
});
}
function showHint(msg, duration) {
powerHint.textContent = msg;
setPowerHint(msg);
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => {
powerHint.textContent = readyText();
setPowerHint(readyText());
}, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
if (HINT_ERROR_RE.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -3206,6 +3333,7 @@ function refreshWavelengthDisplay(hz) {
wavelengthEl.textContent = formatWavelength(hz);
}
function refreshFreqDisplay() {
if (window.trx?.modules.vchan?.interceptFreqDisplay()) return;
if (lastFreqHz == null || freqDirty) return;
freqEl.value = formatFrequencyForStep(lastFreqHz, jogUnit);
refreshWavelengthDisplay(lastFreqHz);
@@ -3625,12 +3753,19 @@ async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardB
}
var _freqOptimisticHz = null;
var _freqOptimisticSeq = 0;
function armOptimisticFrequency(freqHz) {
if (!isFiniteNumber(freqHz)) return;
_freqOptimisticSeq += 1;
_freqOptimisticHz = Math.round(freqHz);
}
function setRigFrequency(freqHz) {
const targetHz = Math.round(freqHz);
if (!freqAllowed(targetHz)) {
showUnsupportedFreqPopup(targetHz);
throw new Error(`Unsupported frequency: ${targetHz}`);
}
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
void window.trx?.modules.vchan?.takeSchedulerControl();
const prevFreqHz = lastFreqHz;
const seq = ++_freqOptimisticSeq;
_freqOptimisticHz = targetHz;
@@ -4013,6 +4148,7 @@ function setDisabled(disabled) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
var serverVersion = null;
var serverBuildDate = null;
@@ -4305,13 +4441,13 @@ function render(update) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
powerHint.textContent = "Initializing rig…";
setPowerHint("Initializing rig…");
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
powerHint.textContent = "Rig not fully initialized yet";
setPowerHint("Rig not fully initialized yet");
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -4344,6 +4480,7 @@ function render(update) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -4470,6 +4607,7 @@ function render(update) {
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
if (!onVirtual) {
modeEl.value = modeUpper2;
syncModePicker();
if (modeUpper2 === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -4604,6 +4742,7 @@ function render(update) {
const sUnits = dbmToSUnits(update.status.rx.sig);
sigLastSUnits = sUnits;
sigLastDbm = update.status.rx.sig;
recordSquelchMeterSample(update.status.rx.sig);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
signalBar.style.width = `${pct}%`;
signalValue.innerHTML = formatSignal(sUnits);
@@ -4630,7 +4769,7 @@ function render(update) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
setPowerHint("State unknown");
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
@@ -4727,7 +4866,7 @@ function render(update) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
powerHint.textContent = readyText();
setPowerHint(readyText());
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -4805,11 +4944,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
powerHint.textContent = "trx-server connection lost";
setPowerHint("trx-server connection lost");
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) powerHint.textContent = readyText();
if (data.initialized) setPowerHint(readyText());
}
} catch (e) {
console.error("Bad event data", e);
@@ -4832,7 +4971,7 @@ function connect() {
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying…";
setPowerHint("trx-client connection lost, retrying…");
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -4842,7 +4981,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15e3) {
powerHint.textContent = "trx-client connection lost, retrying…";
setPowerHint("trx-client connection lost, retrying…");
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
void pollFreshSnapshot();
@@ -4947,7 +5086,7 @@ async function switchRigFromSelect(selectEl) {
rigSwitchInProgress = true;
setControlPending(selectEl, true);
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
updateRigIdentitySummary(nextRig, true);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
@@ -4955,7 +5094,6 @@ async function switchRigFromSelect(selectEl) {
lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch();
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
@@ -4974,7 +5112,6 @@ async function switchRigFromSelect(selectEl) {
} catch (err) {
console.error("select_rig failed:", err);
selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally {
rigSwitchInProgress = false;
@@ -5229,6 +5366,33 @@ if (jogMultEl) {
}
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
var modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -5237,6 +5401,7 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -5254,6 +5419,7 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
modeEl.addEventListener("change", applyModeFromPicker);
@@ -5450,6 +5616,39 @@ var _activeTab = "main";
function tabFromPath2(pathname = window.location.pathname) {
return tabFromPath(pathname);
}
var pendingMapTarget = null;
function applyMapTarget(target) {
const map = window.trx.modules.map;
if (!map) return false;
if (target.kind === "position") {
map.focusMapPosition?.(target.lat, target.lon);
} else {
map.focusMapLocator?.(target.grid, target.preferredType);
}
return true;
}
function requestMapTarget(target) {
pendingMapTarget = target;
navigateToTab("map");
if (window.trx.modules.map) {
requestAnimationFrame(() => {
drainPendingMapTarget();
});
}
}
function drainPendingMapTarget() {
const target = pendingMapTarget;
if (!target) return;
if (applyMapTarget(target)) pendingMapTarget = null;
}
window.navigateToAprsMap = (lat, lon) => {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
requestMapTarget({ kind: "position", lat, lon });
};
window.navigateToMapLocator = (grid, preferredType = null) => {
if (!grid) return;
requestMapTarget({ kind: "locator", grid, preferredType });
};
var _mapInitTimer = null;
function _initMapWhenReady() {
const loadingEl2 = document.getElementById("map-loading");
@@ -5466,6 +5665,7 @@ function _initMapWhenReady() {
requestAnimationFrame(() => {
map.sizeAprsMapToViewport();
map.aprsMap?.invalidateSize();
drainPendingMapTarget();
});
});
return;
@@ -5494,6 +5694,11 @@ function navigateToTab(name, options = {}) {
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) {
const inToolsMenu = !!document.querySelector(`#mobile-more-menu [data-navigate-tab="${name}"]`);
toolsBtn.classList.toggle("active", inToolsMenu);
}
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -5713,6 +5918,9 @@ Object.defineProperties(trxState, {
decodeHistoryRetentionMin: { get() {
return decodeHistoryRetentionMin;
} },
authEnabled: { get() {
return authEnabled;
} },
authRole: { get() {
return authRole;
} },
@@ -5765,6 +5973,19 @@ Object.defineProperties(trxState, {
} },
lastModeName: { get() {
return lastModeName;
}, set(v) {
lastModeName = v;
} },
jogUnit: { get() {
return jogUnit;
} },
rxActive: { get() {
return rxActive;
} },
audioChannelOverride: { get() {
return _audioChannelOverride;
}, set(v) {
_audioChannelOverride = v;
} },
lastSpectrumData: { get() {
return lastSpectrumData;
@@ -5816,6 +6037,23 @@ var trxCore = Object.freeze({
scheduleUiFrameJob,
navigateToTab,
rigBadgeColor,
setRigFrequency,
applyLocalTunedFrequency,
armOptimisticFrequency,
syncBandwidthInput,
scheduleSpectrumDraw,
onDecoderRegistryReady,
syncModePicker,
formatFreqForStep: formatFrequencyForStep,
refreshFreqDisplay,
setJogDivisor,
mwDefaultsForMode,
resetRdsDisplay,
positionRdsPsOverlay,
updateWfmControls,
updateSdrSquelchControlVisibility,
startRxAudio,
stopRxAudio,
latLonToMaidenhead,
locatorToLatLon,
haversineKm,
@@ -6018,14 +6256,18 @@ var wfmCciValEl = document.getElementById("wfm-cci-val");
var wfmAciFillEl = document.getElementById("wfm-aci-fill");
var wfmAciValEl = document.getElementById("wfm-aci-val");
var samControlsCol = document.getElementById("sam-controls-col");
var modeControlsRow = document.getElementById("mode-controls-row");
var samStereoWidthEl = document.getElementById("sam-stereo-width");
var samCarrierSyncEl = document.getElementById("sam-carrier-sync");
var sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
var sdrSquelchEl = document.getElementById("sdr-squelch");
var sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
var sdrSquelchDbEl = document.getElementById("sdr-squelch-db");
var sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
var sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle");
var squelchLineEl = document.getElementById("spectrum-squelch-line");
var squelchGripEl = document.getElementById("spectrum-squelch-grip");
var squelchLabelEl = document.getElementById("spectrum-squelch-label");
var SDR_SQUELCH_MIN_DB = -120;
var SDR_SQUELCH_MAX_DB = -30;
var syncFromServerSdrSquelch = false;
var sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -6114,89 +6356,191 @@ function normalizeWfmDenoiseLevel(value) {
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto";
}
function clampSdrSquelchPercent(value) {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
var sdrSquelchEnabled = loadSetting("sdrSquelchEnabled", false);
var sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
function clampSdrSquelchDb(value) {
if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
}
function sdrSquelchPercentToServer(percent) {
const pct = clampSdrSquelchPercent(percent);
if (pct <= 0) {
return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
function sdrSquelchIsPassing() {
if (!sdrSquelchEnabled) return true;
if (!isFiniteNumber(sigLastDbm)) return false;
return sigLastDbm >= sdrSquelchThresholdDb;
}
function renderSdrSquelch() {
if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
}
const ratio = pct / 100;
const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return { enabled: true, thresholdDb };
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
const state = !sdrSquelchEnabled ? "off" : sdrSquelchIsPassing() ? "open" : "closed";
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : state === "open" ? "Squelch on, open" : "Squelch on, closed"
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
positionSquelchLine();
}
function sdrSquelchServerToPercent(enabled, thresholdDb) {
if (!enabled) return 0;
if (!isFiniteNumber(thresholdDb)) return 0;
const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return clampSdrSquelchPercent(ratio * 100);
function positionSquelchLine() {
if (!squelchLineEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
const canvas = document.getElementById("spectrum-canvas");
const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM" && !!canvas && canvas.clientHeight > 0 && getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
if (!visible) {
squelchLineEl.style.display = "none";
return;
}
const dbMin = spectrumFloor;
const dbMax = spectrumFloor + spectrumRange;
const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
squelchLineEl.style.display = "";
squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
}
function updateSdrSquelchPctLabel() {
if (!sdrSquelchEl || !sdrSquelchPctEl) return;
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
function submitSdrSquelch() {
if (!sdrSquelchSupported) return;
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath(
`/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`
).catch(() => {
});
}
function setSdrSquelch(thresholdDb, enabled, options = {}) {
sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
sdrSquelchEnabled = enabled;
renderSdrSquelch();
if (options.submit !== false) submitSdrSquelch();
}
var SQUELCH_NOISE_WINDOW_MS = 1e4;
var SQUELCH_NOISE_MARGIN_DB = 5;
var SQUELCH_MEASURE_MS = 1500;
var squelchMeterSamples = [];
function recordSquelchMeterSample(db) {
if (!isFiniteNumber(db)) return;
const now = Date.now();
squelchMeterSamples.push({ t: now, v: db });
while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) {
squelchMeterSamples.shift();
}
}
function squelchNoiseFloorDb() {
const now = Date.now();
const values = squelchMeterSamples.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS).map((sample) => sample.v).sort((a, b) => a - b);
if (values.length < 4) return null;
return values[Math.floor((values.length - 1) * 0.2)] ?? null;
}
function autoSquelchThresholdDb() {
const noiseDb = squelchNoiseFloorDb();
if (noiseDb == null) return null;
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled, thresholdDb) {
if (!sdrSquelchEl) return;
if (document.activeElement === sdrSquelchEl) return;
const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
syncFromServerSdrSquelch = true;
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
syncFromServerSdrSquelch = false;
saveSetting("sdrSquelchPct", pct);
if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
renderSdrSquelch();
}
function submitSdrSquelchPercent(percent) {
if (!sdrSquelchSupported) return;
const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
postPath(
`/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`
).catch(() => {
var squelchDragPointerId = null;
var squelchDragSubmitAt = 0;
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("change", () => {
setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
});
sdrSquelchDbEl.addEventListener("blur", () => {
renderSdrSquelch();
});
}
if (sdrSquelchEl) {
const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
sdrSquelchEl.value = String(savedPct);
updateSdrSquelchPctLabel();
sdrSquelchEl.addEventListener("input", () => {
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
if (!syncFromServerSdrSquelch) {
submitSdrSquelchPercent(pct);
}
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
});
}
function applyAutoSquelch(threshold) {
setSdrSquelch(threshold, true);
showHint(`Squelch ${threshold} dB`, 1500);
}
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
let pct = 0;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
pct = clampSdrSquelchPercent(
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
);
const threshold = autoSquelchThresholdDb();
if (threshold != null) {
applyAutoSquelch(threshold);
return;
}
sdrSquelchAutoBtn.disabled = true;
showHint("Measuring the noise…");
setTimeout(() => {
sdrSquelchAutoBtn.disabled = false;
const measured = autoSquelchThresholdDb();
if (measured == null) {
showHint("No meter to measure the noise from", 1800);
return;
}
applyAutoSquelch(measured);
}, SQUELCH_MEASURE_MS);
});
}
if (squelchGripEl) {
const dbFromClientY = (clientY) => {
const canvas = document.getElementById("spectrum-canvas");
if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
const rect = canvas.getBoundingClientRect();
const frac = 1 - (clientY - rect.top) / rect.height;
return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
};
squelchGripEl.addEventListener("pointerdown", (event) => {
if (!sdrSquelchSupported) return;
squelchDragPointerId = event.pointerId;
squelchGripEl.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
});
squelchGripEl.addEventListener("pointermove", (event) => {
if (squelchDragPointerId !== event.pointerId) return;
sdrSquelchThresholdDb = dbFromClientY(event.clientY);
renderSdrSquelch();
const now = Date.now();
if (now - squelchDragSubmitAt > 200) {
squelchDragSubmitAt = now;
submitSdrSquelch();
}
if (sdrSquelchEl) {
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
});
const endDrag = (event) => {
if (squelchDragPointerId !== event.pointerId) return;
squelchDragPointerId = null;
submitSdrSquelch();
};
squelchGripEl.addEventListener("pointerup", endDrag);
squelchGripEl.addEventListener("pointercancel", endDrag);
squelchGripEl.addEventListener("keydown", (event) => {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
} else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
} else {
return;
}
submitSdrSquelchPercent(pct);
event.preventDefault();
});
}
if (wfmAudioModeEl) {
@@ -6325,6 +6669,7 @@ function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
}
if (!hasWebCodecs) {
rxAudioBtn.disabled = true;
@@ -7055,15 +7400,10 @@ function volWheel(slider, pctEl, getGain, storageKey) {
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
if (sdrSquelchEl) {
sdrSquelchEl.addEventListener("wheel", (e) => {
e.preventDefault();
const step = e.deltaY < 0 ? 2 : -2;
const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
sdrSquelchEl.value = String(next);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", next);
submitSdrSquelchPercent(next);
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("wheel", (event) => {
event.preventDefault();
setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
}, { passive: false });
}
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
@@ -7292,7 +7632,7 @@ function connectDecode() {
return;
}
if (data.type === "group") {
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message) && typeof message.type === "string") : [];
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message)) : [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
}
@@ -7806,6 +8146,7 @@ function flushMeterDom() {
const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits;
sigLastDbm = dbm;
recordSquelchMeterSample(dbm);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
if (signalBar) signalBar.style.width = `${pct}%`;
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
@@ -8110,6 +8451,7 @@ function drawSpectrum(data) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
positionSquelchLine();
function hzToX(hz) {
return (hz - range.visLoHz) / range.visSpanHz * W;
}
@@ -8442,7 +8784,8 @@ function updateBookmarkAxis(range) {
updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap);
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
const hasVisible = visBookmarks.length > 0;
axisEl.classList.toggle("bm-axis-visible", hasVisible);
axisEl.classList.add("bm-axis-visible");
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
if (!hasVisible) {
if (axisEl.dataset.bmKey) {
axisEl.replaceChildren();
@@ -8737,31 +9080,15 @@ window.addEventListener("keydown", (event) => {
}
if (key === "q") {
event.preventDefault();
if (sdrSquelchSupported && sdrSquelchEl) {
const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
let nextPct;
if (current > 0) {
nextPct = 0;
if (sdrSquelchSupported) {
if (sdrSquelchEnabled) {
setSdrSquelch(sdrSquelchThresholdDb, false);
showHint("Squelch off", 1200);
} else {
let auto = 30;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
auto = clampSdrSquelchPercent(
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
);
}
}
nextPct = auto;
const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB ? sdrSquelchThresholdDb : autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25;
setSdrSquelch(threshold, true);
showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
}
sdrSquelchEl.value = String(nextPct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", nextPct);
submitSdrSquelchPercent(nextPct);
showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
@@ -9164,8 +9491,8 @@ var bandplanCacheKey = "";
var bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
var bandplanRegionSelect = document.getElementById("bandplan-region-select");
var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
(function loadBandplanJson() {
fetch("/bandplan.json").then(async (response) => {
function loadBandplanJson() {
return fetch("/bandplan.json").then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
}).then((data) => {
@@ -9173,9 +9500,12 @@ var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
bandplanData = data;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
}).catch(() => {
if (lastSpectrumData) scheduleSpectrumDraw();
}).catch((err) => {
console.warn("Band plan unavailable", err);
});
})();
}
void loadBandplanJson();
if (bandplanRegionSelect) {
bandplanRegionSelect.value = bandplanRegion;
bandplanRegionSelect.addEventListener("change", () => {
@@ -9237,24 +9567,28 @@ function bandplanVisibleSegments(region, loHz, hiHz) {
}
return result;
}
function _hideBandplanStrip() {
function _clearBandplanStrip(reserveSpace) {
if (!bandplanStripEl) return;
bandplanStripEl.classList.remove("bp-visible");
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
if (bandplanCacheKey) {
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
}
function updateBandplanStrip(range) {
if (!bandplanStripEl) return;
if (!range || bandplanRegion === "off" || !bandplanData) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(false);
return;
}
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
if (segments.length === 0) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(true);
return;
}
bandplanStripEl.classList.add("bp-visible");
bandplanStripEl.classList.remove("bp-empty");
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
const stripW = bandplanStripEl.clientWidth || 1;
if (bandplanCacheKey !== newKey) {
@@ -7,13 +7,17 @@ import {
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
} from "./chunk-ROTCLFXS.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/aprs.ts
var aprsWindow = window;
var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
var showAprsHint = (message, durationMs) => {
aprsWindow.showHint?.(message, durationMs);
hostCore.showHint(message, durationMs);
};
var aprsStatus = document.getElementById("aprs-status");
var aprsPacketsEl = document.getElementById("aprs-packets");
@@ -58,8 +62,8 @@ function scheduleAprsBarUpdate() {
});
}
function aprsDistanceText(pkt) {
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon);
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
@@ -260,7 +264,7 @@ document.getElementById("settings-clear-aprs-history")?.addEventListener("click"
void (async () => {
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aprsWindow.postPath?.("/clear_aprs_decode");
await hostCore.postPath("/clear_aprs_decode");
resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
@@ -1,3 +1,7 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/background-decode.ts
var bgdWindow = window;
(function() {
@@ -104,7 +108,7 @@ var bgdWindow = window;
}
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false;
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
@@ -1,3 +1,8 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/bookmarks.ts
var bridge = window;
function bmEl(id) {
@@ -5,6 +10,9 @@ function bmEl(id) {
if (!element) throw new Error(`Missing bookmark element #${id}`);
return element;
}
function bmOptionalEl(id) {
return document.getElementById(id);
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
@@ -34,7 +42,7 @@ function bmEsc(str) {
return d.innerHTML;
}
function bmCanControl() {
return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control";
return !hostState.authEnabled || hostState.authRole === "control";
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
@@ -44,8 +52,7 @@ function bmSyncAccess() {
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
}
function bmListScope() {
const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null;
return rig || "general";
return hostState.lastActiveRigId || "general";
}
async function bmFetchOverlay() {
const overlayScope = bmListScope();
@@ -61,7 +68,7 @@ async function bmFetchOverlay() {
if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList);
}
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
hostCore.scheduleSpectrumDraw();
}
async function bmFetch(categoryFilter) {
let url = "/bookmarks";
@@ -184,12 +191,12 @@ function bmChangePage(delta) {
bmRender(bmFilteredList);
}
function bmReadDecoders() {
return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
return hostState.decoderRegistry.filter((d) => d.bookmark_selectable).filter((d) => bmOptionalEl("bm-dec-" + d.id)?.checked).map((d) => d.id);
}
function bmWriteDecoders(decoders) {
const set = new Set(decoders || []);
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
const el = bmEl("bm-dec-" + d.id);
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const el = bmOptionalEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id);
});
}
@@ -197,7 +204,7 @@ function bmBuildDecoderCheckboxes() {
const container = bmEl("bm-decoder-checkboxes");
if (!container) return;
container.innerHTML = "";
(bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const label = document.createElement("label");
label.className = "bm-decoder-check";
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
@@ -227,19 +234,17 @@ function bmCloseForm() {
if (wrap) wrap.style.display = "none";
}
function bmPrefillFromStatus() {
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
const freqHz = hostState.lastFreqHz;
if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz));
}
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
bmEl("bm-mode").value = bridge.lastModeName;
if (hostState.lastModeName) {
bmEl("bm-mode").value = hostState.lastModeName;
}
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
}
const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => {
const btn = bmEl(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
}).map((d) => d.id);
const activeDecoders = hostState.decoderRegistry.filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true").map((d) => d.id);
bmWriteDecoders(activeDecoders);
}
async function bmSave(e) {
@@ -320,55 +325,44 @@ async function bmDelete(id) {
}
function bmApply(bm) {
try {
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
bridge.modeEl.value = (bm.mode || "").toUpperCase();
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
if (typeof bridge.currentBandwidthHz !== "undefined") {
bridge.currentBandwidthHz = bm.bandwidth_hz;
}
bridge.currentBandwidthHz = bm.bandwidth_hz;
if (typeof bridge.syncBandwidthInput === "function") {
bridge.syncBandwidthInput(bm.bandwidth_hz);
}
hostState.currentBandwidthHz = bm.bandwidth_hz;
hostCore.syncBandwidthInput(bm.bandwidth_hz);
}
if (typeof bridge.applyLocalTunedFrequency === "function") {
if (typeof bridge._freqOptimisticSeq !== "undefined") {
++bridge._freqOptimisticSeq;
bridge._freqOptimisticHz = bm.freq_hz;
}
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
}
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
bridge.scheduleSpectrumDraw();
hostCore.armOptimisticFrequency(bm.freq_hz);
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
if (hostState.lastSpectrumData) {
hostCore.scheduleSpectrumDraw();
}
const tunePromise = (async () => {
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
await bridge.trx.modules.vchan?.takeSchedulerControl();
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
}
if (bm.bandwidth_hz) {
const bwHandledByVchan = await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
const bwHandledByVchan = await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
if (!bwHandledByVchan) {
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
}
}
if (typeof bridge.setRigFrequency === "function") {
await bridge.setRigFrequency(bm.freq_hz);
} else {
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
}
hostCore.setRigFrequency(bm.freq_hz);
})();
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (bridge.decoderRegistry || []).filter(
const allToggleDecoders = hostState.decoderRegistry.filter(
(d) => d.activation === "toggle"
);
const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status";
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
const rigId = hostState.lastActiveRigId;
if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId);
}
const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return;
@@ -387,7 +381,7 @@ function bmApply(bm) {
wanted = currentlyOn;
}
if (wanted !== currentlyOn) {
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
}
}
if (toggles.length) await Promise.all(toggles);
@@ -399,8 +393,6 @@ function bmApply(bm) {
console.error("Failed to apply bookmark:", err);
}
}
bridge.trx ??= {};
bridge.trx.modules ??= {};
bridge.trx.modules.bookmarks = {
get overlayList() {
return bmOverlayList;
@@ -439,8 +431,8 @@ function bmUpdateSelectionUi() {
function bmPopulateMoveTarget() {
const sel = bmEl("bm-move-target");
if (!sel) return;
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
const rigIds = hostState.lastRigIds;
const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value;
sel.innerHTML = "";
if (bmScope !== "general") {
@@ -545,8 +537,8 @@ async function bmDeleteSelected() {
function bmPopulateScopePicker() {
const picker = bmEl("bm-scope-picker");
if (!picker) return;
const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : [];
const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {};
const rigIds = hostState.lastRigIds;
const displayNames = hostState.lastRigDisplayNames;
const prev = picker.value;
while (picker.options.length > 1) picker.remove(1);
rigIds.forEach((id) => {
@@ -565,9 +557,7 @@ function bmPopulateScopePicker() {
(function initBookmarks() {
bmSyncAccess();
bmBuildDecoderCheckboxes();
if (typeof bridge.onDecoderRegistryReady === "function") {
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
bmPopulateScopePicker();
const scopePicker = bmEl("bm-scope-picker");
if (scopePicker) {
@@ -0,0 +1,9 @@
// src/plugins/host.ts
var host = window;
var hostState = host.trx.state;
var hostCore = host.trx.core;
export {
hostState,
hostCore
};
@@ -1,5 +1,13 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/ftx-family.ts
var bridge = window;
function formatBarTime(timestampMs) {
if (!timestampMs) return "--:--:--";
return new Date(timestampMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function finiteNumber(value) {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null;
@@ -198,7 +206,7 @@ function initializeFtxDecoder(config) {
let html = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
const snr = finiteNumber(message.snr_db);
const delta = finiteNumber(message.dt_s);
const frequency = displayFrequency(message.freq_hz);
@@ -243,7 +251,7 @@ function initializeFtxDecoder(config) {
void (async () => {
try {
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
await bridge.postPath?.(`/toggle_${id}_decode`);
await hostCore.postPath(`/toggle_${id}_decode`);
} catch (error) {
console.error(`${label} toggle failed`, error);
}
@@ -253,7 +261,7 @@ function initializeFtxDecoder(config) {
void (async () => {
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
try {
await bridge.postPath?.(`/clear_${id}_decode`);
await hostCore.postPath(`/clear_${id}_decode`);
reset();
} catch (error) {
console.error(`${label} history clear failed`, error);
@@ -75,8 +75,51 @@ function escapeAprsCharacter(character) {
if (character === '"') return "&quot;";
return character;
}
var APRS_SPRITE_COLUMNS = 16;
var APRS_SPRITE_CELL_PX = 24;
var APRS_SPRITE_FIRST_CODE = 33;
var APRS_SPRITE_LAST_CODE = 126;
function aprsSpriteOffset(code) {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
function aprsSymbolSprite(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`
};
}
function renderLocalAprsSymbol(packet, escapeHtml) {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
@@ -107,6 +150,7 @@ export {
collapseAprsDuplicates,
aprsHexBytes,
renderAprsInfo,
aprsSymbolSprite,
renderLocalAprsSymbol,
normalizeAprsPacket
};
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/cw.ts
var cwWindow = window;
var cwStatusEl = document.getElementById("cw-status");
@@ -25,7 +29,7 @@ var cwBarCurrentLine = null;
var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null;
function escapeCwHtml(input) {
return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
return hostCore.escapeMapHtml(input);
}
function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled;
@@ -246,7 +250,7 @@ async function setCwTone(tone, { syncInput = true } = {}) {
cwToneInput.value = String(clamped);
}
try {
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
@@ -259,7 +263,7 @@ if (cwAutoInput) {
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (error) {
console.error("CW auto toggle failed", error);
@@ -276,7 +280,7 @@ if (cwWpmInput) {
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = String(wpm);
try {
await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
} catch (error) {
console.error("CW WPM set failed", error);
}
@@ -312,7 +316,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
void (async () => {
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await cwWindow.postPath?.("/clear_cw_decode");
await hostCore.postPath("/clear_cw_decode");
resetCwHistoryView();
} catch (error) {
console.error("CW history clear failed", error);
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-SGMG5LG2.js";
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
@@ -1,6 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-SGMG5LG2.js";
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
@@ -2,7 +2,8 @@ import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-SGMG5LG2.js";
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
installFtxCompatibilityHelpers();
@@ -7,11 +7,15 @@ import {
normalizeAprsPacket,
renderAprsInfo,
renderLocalAprsSymbol
} from "./chunk-M2I6DH4X.js";
} from "./chunk-ROTCLFXS.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/hf-aprs.ts
var hfAprsWindow = window;
var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input);
var hfAprsStatus = document.getElementById("hf-aprs-status");
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
@@ -44,8 +48,8 @@ function scheduleHfAprsHistoryRender() {
renderHfAprsHistory();
}
function hfAprsDistanceText(pkt) {
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return "";
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon);
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
@@ -134,10 +138,10 @@ function renderHfAprsRow(pkt, isFresh) {
const clipboard = Reflect.get(navigator, "clipboard");
if (clipboard) {
await clipboard.writeText(raw);
hfAprsWindow.showHint?.("Coordinates copied", 1200);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hfAprsWindow.showHint?.("Copy failed", 1500);
hostCore.showHint("Copy failed", 1500);
}
})();
});
@@ -201,7 +205,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
try {
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode");
await hostCore.postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
@@ -211,7 +215,7 @@ document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("cli
void (async () => {
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
await hostCore.postPath("/clear_hf_aprs_decode");
resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
@@ -1,3 +1,7 @@
import {
aprsSymbolSprite
} from "./chunk-ROTCLFXS.js";
// src/map-core.ts
function mapEl(id) {
const element = document.querySelector(`#${CSS.escape(id)}`);
@@ -1643,42 +1647,29 @@ var mapWindow = window;
return;
}
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight;
if (!mapIsFullscreen() && footer) {
let bottom = window.innerHeight;
if (footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = fr.top;
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
}
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
function aprsSymbolIcon(symbolTable, symbolCode) {
if (!symbolTable || !symbolCode) return null;
const table = symbolTable === "/" ? "primary" : "alternate";
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
const html = sprite ? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img" style="background-position:${sprite.backgroundPosition}" title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>` : `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
return L.divIcon({
className: "",
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
html,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
});
}
mapWindow.navigateToAprsMap = function(lat, lon) {
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
function focusMapPosition(lat, lon) {
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -1690,19 +1681,10 @@ var mapWindow = window;
});
});
}
};
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
}
function focusMapLocator(grid, preferredType = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => {
t.classList.remove("active");
});
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -1747,7 +1729,7 @@ var mapWindow = window;
requestAnimationFrame(focusMarker);
});
return true;
};
}
function buildReceiverPopupHtml(rigIds) {
const call = T.serverCallsign || T.ownerCallsign || "Receiver";
let meta = "";
@@ -2332,7 +2314,7 @@ var mapWindow = window;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
focusMapLocator(entry.sourceGrid, entry.sourceType);
}
});
const head = document.createElement("div");
@@ -2438,7 +2420,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -2544,7 +2526,7 @@ var mapWindow = window;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
const head = document.createElement("div");
@@ -3049,6 +3031,8 @@ var mapWindow = window;
}
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/sat.ts
var satWindow = window;
var satDom = {
@@ -243,7 +247,7 @@ lrptDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
try {
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await satWindow.postPath?.("/toggle_lrpt_decode");
await hostCore.postPath("/toggle_lrpt_decode");
} catch (e) {
console.error("LRPT toggle failed", e);
}
@@ -264,7 +268,7 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
void (async () => {
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await satWindow.postPath?.("/clear_lrpt_decode");
await hostCore.postPath("/clear_lrpt_decode");
resetSatHistoryView();
} catch (e) {
console.error("Weather satellite history clear failed", e);
@@ -1,3 +1,7 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/scheduler.ts
var schedulerWindow = window;
var wiredElements = /* @__PURE__ */ new WeakSet();
@@ -6,6 +10,9 @@ function schedulerEl(id) {
if (!element) throw new Error(`Missing scheduler element #${id}`);
return element;
}
function schedulerOptionalEl(id) {
return document.getElementById(id);
}
(function() {
"use strict";
let schedulerRole = null;
@@ -359,8 +366,8 @@ function schedulerEl(id) {
renderSatelliteSection();
if (mode === "grayline" && currentConfig && currentConfig.grayline) {
const gl = currentConfig.grayline;
const lat = gl.lat ?? schedulerWindow.serverLat ?? "";
const lon = gl.lon ?? schedulerWindow.serverLon ?? "";
const lat = gl.lat ?? hostState.serverLat ?? "";
const lon = gl.lon ?? hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl = schedulerEl("scheduler-gl-grid");
@@ -373,8 +380,8 @@ function schedulerEl(id) {
renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id);
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
} else if (mode === "grayline") {
const lat = schedulerWindow.serverLat ?? "";
const lon = schedulerWindow.serverLon ?? "";
const lat = hostState.serverLat ?? "";
const lon = hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl2 = schedulerEl("scheduler-gl-grid");
@@ -624,7 +631,7 @@ function schedulerEl(id) {
return '<line class="sch-timeline-needle" x1="' + x.toFixed(1) + '" y1="2" x2="' + x.toFixed(1) + '" y2="38" /><polygon class="sch-timeline-needle-head" points="' + (x - 3).toFixed(1) + ",2 " + (x + 3).toFixed(1) + ",2 " + x.toFixed(1) + ',6" />';
}
function renderTimelineNeedle() {
const g = schedulerEl("sch-timeline-needle-g");
const g = schedulerOptionalEl("sch-timeline-needle-g");
if (g) g.innerHTML = timelineNeedleSvg();
}
function schInlineEdit(tr, entry, idx) {
@@ -1213,8 +1220,8 @@ function schedulerEl(id) {
markDirty: markSchedulerDirty
};
schedulerWindow.trx.modules.scheduler = schedulerService;
if (schedulerWindow.authRole != null) {
initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole);
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
wireSchedulerEvents();
}
})();
@@ -1,3 +1,8 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/vchan.ts
var vchanWindow = window;
var vchanSessionId = null;
@@ -63,7 +68,7 @@ function vchanStartSchedulerReleasePolling() {
}
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
const rigId = vchanRigId || hostState.lastActiveRigId || null;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
@@ -161,14 +166,12 @@ function vchanRender() {
});
picker.appendChild(addBtn);
vchanSyncAccentUI();
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
}
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanRenderSchedulerRelease();
}
async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0;
const freqHz = typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0 ? hostState.lastFreqHz : 0;
const modeEl = document.getElementById("mode");
const mode = modeEl ? modeEl.value || "USB" : "USB";
try {
@@ -251,11 +254,11 @@ async function vchanSubscribe(channelId) {
}
function vchanReconnectAudio() {
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
vchanWindow._audioChannelOverride = ch?.id ?? null;
if (!vchanWindow.rxActive) return;
vchanWindow.stopRxAudio?.();
hostState.audioChannelOverride = ch?.id ?? null;
if (!hostState.rxActive) return;
hostCore.stopRxAudio();
setTimeout(() => {
vchanWindow.startRxAudio?.();
hostCore.startRxAudio();
}, 300);
}
function vchanApplyCapabilities(caps) {
@@ -276,35 +279,34 @@ function vchanUpdateFreqDisplay() {
if (!ch) return;
const el = document.getElementById("freq");
if (!el) return;
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
}
function vchanSyncModeDisplay() {
const modeEl = document.getElementById("mode");
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof vchanWindow.lastModeName === "string") {
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
vchanWindow.setJogDivisor?.(10);
vchanWindow.resetRdsDisplay?.();
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
vchanWindow.resetRdsDisplay?.();
if (typeof hostState.lastModeName === "string") {
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
hostCore.setJogDivisor(10);
hostCore.resetRdsDisplay();
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
hostCore.resetRdsDisplay();
}
vchanWindow.lastModeName = modeUpper;
hostState.lastModeName = modeUpper;
}
vchanWindow.updateWfmControls?.();
vchanWindow.updateSdrSquelchControlVisibility?.();
hostCore.updateWfmControls();
hostCore.updateSdrSquelchControlVisibility();
if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi();
} else {
vchanWindow.positionRdsPsOverlay?.();
hostCore.positionRdsPsOverlay();
}
}
function vchanSyncBwDisplay() {
@@ -314,12 +316,12 @@ function vchanSyncBwDisplay() {
const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return;
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
if (bwHz === 0) {
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
vchanWindow.currentBandwidthHz = bwHz;
hostState.currentBandwidthHz = bwHz;
}
}
function vchanSyncAccentUI() {
@@ -333,25 +335,20 @@ function vchanSyncAccentUI() {
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else {
origRefreshFreqDisplay?.();
}
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
hostCore.refreshFreqDisplay();
}
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
}
var origRefreshFreqDisplay = null;
function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return;
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
const center = vchanWindow.lastSpectrumData.center_hz;
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
const center = hostState.lastSpectrumData.center_hz;
if (Math.abs(freqHz - center) > halfSpan) {
if (vchanWindow.showHint) {
vchanWindow.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3e3
);
}
hostCore.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3e3
);
return;
}
}
@@ -411,6 +408,19 @@ async function vchanInterceptBandwidth(bwHz) {
await vchanSetChannelBandwidth(bwHz);
return true;
}
function vchanInterceptFrequency(freqHz) {
if (!vchanIsOnVirtual()) return false;
const targetHz = Math.round(freqHz);
hostCore.armOptimisticFrequency(targetHz);
hostCore.applyLocalTunedFrequency(targetHz);
vchanSetChannelFreq(freqHz);
return true;
}
function vchanInterceptFreqDisplay() {
if (!vchanIsOnVirtual()) return false;
vchanUpdateFreqDisplay();
return true;
}
vchanWindow.trx ??= {};
vchanWindow.trx.modules ??= {};
vchanWindow.trx.modules.vchan = {
@@ -427,27 +437,11 @@ vchanWindow.trx.modules.vchan = {
isOnVirtual: vchanIsOnVirtual,
interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease
};
(function() {
const original = vchanWindow.setRigFrequency;
vchanWindow.setRigFrequency = function(freqHz) {
if (vchanIsOnVirtual()) {
if (vchanWindow.applyLocalTunedFrequency) {
if (typeof vchanWindow._freqOptimisticSeq === "number") {
vchanWindow._freqOptimisticSeq += 1;
vchanWindow._freqOptimisticHz = Math.round(freqHz);
}
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
}
vchanSetChannelFreq(freqHz);
return;
}
void vchanTakeSchedulerControl();
original?.(freqHz);
};
})();
(function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn");
if (btn) {
@@ -458,13 +452,3 @@ vchanWindow.trx.modules.vchan = {
vchanStartSchedulerReleasePolling();
vchanRenderSchedulerRelease();
})();
(function() {
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
vchanWindow.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay();
return;
}
origRefreshFreqDisplay?.();
};
})();
@@ -1,6 +1,10 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/vdes.ts
var vdesWindow = window;
var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
var vdesStatus = document.getElementById("vdes-status");
var vdesMessagesEl = document.getElementById("vdes-messages");
var vdesFilterInput = document.getElementById("vdes-filter");
@@ -231,7 +235,7 @@ document.getElementById("settings-clear-vdes-history")?.addEventListener("click"
void (async () => {
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await vdesWindow.postPath?.("/clear_vdes_decode");
await hostCore.postPath("/clear_vdes_decode");
resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/wefax.ts
var wefaxWindow = window;
var wefaxDom = {
@@ -299,7 +303,7 @@ if (wefaxDom.toggleBtn) {
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
}
await wefaxWindow.postPath?.("/toggle_wefax_decode");
await hostCore.postPath("/toggle_wefax_decode");
} catch (e) {
console.error("WEFAX toggle failed", e);
}
@@ -310,7 +314,7 @@ if (wefaxDom.clearBtn) {
wefaxDom.clearBtn.addEventListener("click", () => {
void (async () => {
try {
await wefaxWindow.postPath?.("/clear_wefax_decode");
await hostCore.postPath("/clear_wefax_decode");
resetWefaxHistoryView();
} catch (e) {
console.error("WEFAX clear failed", e);
@@ -1,3 +1,7 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/wspr.ts
var wsprWindow = window;
var wsprStatus = document.getElementById("wspr-status");
@@ -229,7 +233,7 @@ wsprDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
try {
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await wsprWindow.postPath?.("/toggle_wspr_decode");
await hostCore.postPath("/toggle_wspr_decode");
} catch (error) {
console.error("WSPR toggle failed", error);
}
@@ -239,7 +243,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
void (async () => {
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await wsprWindow.postPath?.("/clear_wspr_decode");
await hostCore.postPath("/clear_wspr_decode");
resetWsprHistoryView();
} catch (error) {
console.error("WSPR history clear failed", error);
@@ -22,7 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg xmlns="http://www.w3.org/2000/svg" style="display:none">
<symbol id="icon-home" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.8 7 8 2.9 13.2 7"/><path d="M4.3 5.9V13h7.4V5.9"/><path d="M6.8 13V9.3h2.4V13"/></symbol>
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
<symbol id="icon-signal" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="11" width="2.5" height="4" rx="0.5"/><rect x="4.75" y="8" width="2.5" height="7" rx="0.5"/><rect x="8.5" y="5" width="2.5" height="10" rx="0.5"/><rect x="12.25" y="2" width="2.5" height="13" rx="0.5"/></symbol>
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
@@ -46,14 +46,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="tab-bar-nav" aria-label="Primary navigation">
<button class="tab active" data-tab="main">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg>
<span class="tab-label">Main</span>
<span class="tab-label">Radio</span>
</button>
<button class="tab" data-tab="bookmarks">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
<span class="tab-label">Bookmarks</span>
</button>
<button class="tab" data-tab="digital-modes">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-signal"/></svg>
<svg class="tab-icon" aria-hidden="true"><use href="#icon-digital"/></svg>
<span class="tab-label">Digital modes</span>
</button>
<button class="tab" data-tab="map">
@@ -85,7 +85,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="header-rec-btn" class="header-bar-btn header-rec-btn" type="button" aria-label="Toggle recording" title="Toggle recording">REC</button>
<div class="header-rig-switch">
<select id="header-rig-switch-select" aria-label="Select active rig"></select>
<span id="header-rig-summary" class="header-rig-summary" aria-live="polite"></span>
</div>
<div class="header-style-pick">
<select id="header-style-pick-select" aria-label="Select UI style">
@@ -143,6 +142,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
<canvas id="spectrum-canvas" tabindex="0" role="img" aria-label="Spectrum display" aria-describedby="spectrum-text-summary"></canvas>
<p id="spectrum-text-summary" class="visually-hidden">Spectrum data is waiting for the receiver.</p>
<div id="spectrum-squelch-line" class="spectrum-squelch-line" style="display:none;" data-state="closed">
<span class="spectrum-squelch-grip" id="spectrum-squelch-grip" role="slider" tabindex="0"
aria-label="Squelch threshold" aria-valuemin="-120" aria-valuemax="-30" aria-valuenow="-95">SQL <span id="spectrum-squelch-label">-95</span> dB</span>
</div>
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
<div id="spectrum-db-axis" aria-hidden="true"></div>
@@ -213,31 +216,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" />
<div class="label" id="center-freq-label"><span>Center Frequency</span></div>
</div>
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="full-row controls-tray-shell">
<div class="controls-tray-scroll">
<div class="controls-tray">
<div class="controls-row full-row">
<div class="controls-col label-below-col">
<div class="controls-col controls-col-mode label-below-col">
<div class="label"><span>Mode</span></div>
<div class="inline">
<select class="status-input" id="mode" aria-label="Demodulation mode"></select>
<!-- The select stays as the mode's value: a dozen call sites and
several plugins read #mode.value. It is hidden from sight and
from assistive tech; the buttons beside it are the control. -->
<select class="visually-hidden" id="mode" tabindex="-1" aria-hidden="true"></select>
<div id="mode-picker" class="mode-picker" role="group" aria-label="Demodulation mode"></div>
</div>
</div>
<div class="controls-col controls-col-center">
@@ -249,6 +241,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="jog-up" type="button" class="jog-btn">+</button>
</div>
</div>
<div class="controls-col controls-col-step">
<div class="inline step-controls-inline">
<div class="freq-field unit-col">
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
<div class="label"><span>Unit</span></div>
</div>
<div class="freq-field mult-col">
<div class="jog-mult" id="jog-mult">
<button type="button" data-mult="1" class="active" aria-label="Use full tune step">1x</button>
<button type="button" data-mult="10" aria-label="Use one tenth tune step">0.1x</button>
</div>
<div class="label"><span>Step Scale</span></div>
</div>
</div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
<div class="controls-row controls-row-mode full-row" id="mode-controls-row" style="display:none">
<div class="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
<div class="inline wfm-controls-inline">
<label class="wfm-control">
@@ -306,14 +327,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
<div class="label"><span>SAM</span></div>
</div>
<div class="controls-col controls-col-power label-below-col" id="tx-power-col">
<div class="label"><span>Transmit / Power</span></div>
<div class="btn-grid">
<button id="ptt-btn" type="button" aria-pressed="false">Start TX</button>
<button id="power-btn" type="button" aria-pressed="false">Power On</button>
<button id="lock-btn" type="button" aria-pressed="false">Lock Tuning</button>
</div>
</div>
</div>
<div class="full-row label-below-row" id="vfo-row">
<div class="label"><span>VFO</span></div>
@@ -363,32 +376,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="vchan-picker" id="vchan-picker"></div>
</div>
</div>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
</div>
</div>
</div>
</details>
<div class="full-row label-below-row">
<div class="label"><span>Signal</span></div>
<div class="signal" style="gap: 1rem;">
@@ -426,13 +413,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<button id="rx-audio-btn" type="button">Play Audio</button>
<button id="tx-audio-btn" type="button">Transmit Audio</button>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
<label class="vol-label" id="sdr-squelch-wrap" style="display:none;">SQL<input type="range" id="sdr-squelch" min="0" max="100" value="0" class="vol-slider" /><small class="vol-pct" id="sdr-squelch-pct">Open</small><button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set squelch to current noise level">Auto</button></label>
<div id="audio-level">
<div id="audio-level-fill"></div>
<span class="audio-group audio-volume-group">
<span class="audio-group-label">Volume</span>
<label class="vol-label">RX<input type="range" id="rx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="rx-vol-pct">80%</small></label>
<label class="vol-label">TX<input type="range" id="tx-vol" min="0" max="100" value="80" class="vol-slider" /><small class="vol-pct" id="tx-vol-pct">80%</small></label>
</span>
<span class="sql-control" id="sdr-squelch-wrap" style="display:none;">
<button id="sdr-squelch-toggle" type="button" class="sql-toggle" aria-pressed="false" title="Turn the squelch on or off"><span class="sql-state" id="sdr-squelch-state" data-state="off" aria-hidden="true"></span>SQL</button>
<label class="sql-db"><input type="number" id="sdr-squelch-db" min="-120" max="-30" step="1" value="-95" inputmode="numeric" aria-label="Squelch threshold in dB" /><span class="sql-db-unit">dB</span></label>
<button id="sdr-squelch-auto" type="button" class="sql-auto-btn" title="Set the threshold just above the noise floor">Auto</button>
</span>
<span class="audio-group audio-level-group">
<span class="audio-group-label">Level</span>
<div id="audio-level">
<div id="audio-level-fill"></div>
</div>
<small id="audio-status">Off</small>
</span>
</div>
</div>
</div>
</details>
<details id="scheduler-controls" class="advanced-radio-controls scheduler-controls-section">
<summary>Scheduler controls</summary>
<div class="advanced-radio-body">
<div class="scheduler-control-row" style="display:none">
<div class="scheduler-release-wrap">
<div class="scheduler-action-row">
<div class="scheduler-step-controls">
<button id="scheduler-prev-btn" type="button">Previous Entry</button>
<button id="scheduler-next-btn" type="button">Next Entry</button>
</div>
<button id="scheduler-release-btn" type="button">Release to Scheduler</button>
<div id="scheduler-cycle-status" class="interleave-ring-wrap" style="display:none;">
<svg class="interleave-ring" viewBox="0 0 36 36">
<circle class="interleave-ring-bg" cx="18" cy="18" r="15.915" />
<circle class="interleave-ring-fill" id="interleave-ring-fill" cx="18" cy="18" r="15.915"
stroke-dasharray="100" stroke-dashoffset="100" />
</svg>
<div class="interleave-ring-text">
<div class="interleave-ring-label" id="interleave-active-name">--</div>
<div class="interleave-ring-sub" id="interleave-countdown">--</div>
</div>
</div>
</div>
<small id="audio-status" style="min-width: 60px;">Off</small>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
</div>
</div>
</div>
@@ -1052,6 +1077,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template>
</div>
<div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics">
<div class="stats-controls">
<div class="stats-control-group">
@@ -1192,6 +1218,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</section>
</div>
<div id="tab-settings" class="tab-panel" style="display:none;">
<h2 class="section-heading">Settings</h2>
<div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
@@ -1487,6 +1514,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div>
</div>
<div id="tab-about" class="tab-panel" style="display:none;">
<h2 class="section-heading">About</h2>
<div id="auth-badge" style="display:none; margin-bottom: 1rem; padding: 0.5rem; background: var(--bg-secondary); border-radius: 0.25rem; color: var(--text-muted); font-size: 0.85rem;">Authenticated as: <strong id="auth-role-badge">--</strong></div>
<template id="tmpl-about">
<div class="sub-tab-bar">
@@ -1592,10 +1620,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template>
</div>
<div class="footer">
<div class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · <span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span><span id="copyright-year"></span>
<div class="footer-meta">
<span class="copyright">
Built by <a href="https://www.qrzcq.com/call/SP2SJG" target="_blank" rel="noopener">SP2SJG</a> from <a href="https://haxx.space" target="_blank" rel="noopener">haxx.space</a> · &copy; <span id="copyright-year"></span>
</span>
<span class="gh-link-wrap"><a class="gh-link" href="https://git.haxx.space/sjg/trx-rs" target="_blank" rel="noopener" aria-label="Open trx-rs source repository"><svg class="gh-link-icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"></path></svg><span>trx-rs source</span></a></span>
</div>
<div class="hint" id="power-hint" aria-live="polite">Connecting…</div>
<div class="hint" id="power-hint" data-state="busy" aria-live="polite">Connecting…</div>
</div>
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true">
<div class="decode-history-overlay-card">
@@ -18,6 +18,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
--accent-green: #c24b1a;
--accent-yellow: #f0ad4e;
--accent-red: #e55353;
/* Healthy/on indicator. Distinct from --accent-green, which this theme
family uses as the (orange) brand accent rather than a status colour. */
--status-ok: #00d17f;
--vchan-color: #38bdf8;
--control-height: 2.6rem;
--jog-hi: #243a5b;
@@ -107,6 +110,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
--accent-green: #b04317;
--accent-yellow: #b57600;
--accent-red: #cf3f3f;
/* Darker on light backgrounds: #00d17f as text/dots is barely legible there. */
--status-ok: #0a9d63;
--jog-hi: #e6edf8;
--jog-lo: #cdd9eb;
--jog-shadow: rgba(58, 79, 110, 0.18);
@@ -267,11 +272,22 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
-webkit-text-fill-color: currentColor;
}
#center-freq { color: var(--wavelength-fg); }
/* Packs left. As a grid it held a track for every column, but the WFM, SAM and
transmit columns are hidden on most rigs, so the row ended in a hole of dead
space and the power buttons' label chip floated far from the buttons. */
.controls-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto minmax(0, 1fr);
gap: 1rem;
align-items: start;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem 1.25rem;
align-items: flex-start;
}
/* Whatever the current mode adds — WFM's deemphasis and interference meters,
SAM's carrier sync — sits on its own line under the controls every rig has,
instead of stretching the row sideways when one mode happens to be active. */
.controls-row-mode {
padding-top: 0.85rem;
border-top: 1px solid color-mix(in srgb, var(--border-light) 45%, transparent);
}
.controls-col {
min-width: 0;
@@ -299,10 +315,74 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
width: 100%;
margin-top: calc((var(--jog-wheel-size) - var(--control-height)) / 2);
}
/* Mode: a segmented group sized by its content. As a full-width select it took
483px of the row to show "FM"; the modes are all three or four characters and
there are at most twelve of them, so they fit two rows in a third of that. */
.controls-col-mode {
flex: 0 1 auto;
min-width: 0;
}
/* Specific enough to beat .controls-col.label-below-col .inline, whose offset
assumes a --control-height field: the picker and the step groups are both
3.35rem, so they line up with each other beside the wheel. */
.controls-col-mode.label-below-col .inline {
width: auto;
margin-top: calc((var(--jog-wheel-size) - 3.35rem) / 2);
}
.mode-picker {
display: flex;
flex-wrap: wrap;
gap: 1px;
width: max-content;
max-width: 22rem;
border: 1px solid var(--border-light);
border-radius: 6px;
overflow: hidden;
background: var(--border-light);
}
.mode-picker button {
flex: 1 0 3.2rem;
border: none;
border-radius: 0;
min-height: 1.65rem;
height: auto;
padding: 0.32rem 0.55rem;
font-size: 0.82rem;
font-weight: 600;
background: var(--input-bg);
color: var(--text-muted);
cursor: pointer;
}
.mode-picker button:hover:not(:disabled):not(.active) {
color: var(--text);
background: var(--btn-hover-bg);
}
.mode-picker button.active {
background: var(--btn-bg);
color: var(--accent-green);
}
.mode-picker button:disabled {
opacity: 0.55;
cursor: default;
}
/* Tune step: the unit and the scale drive the wheel and the +/- beside it, so
they sit with it rather than up in the frequency row. */
.controls-col-step {
flex: 0 0 auto;
}
.step-controls-inline {
display: flex;
gap: 0.75rem;
align-items: flex-start;
margin-top: calc((var(--jog-wheel-size) - 3.35rem) / 2);
}
.step-controls-inline .label {
justify-content: flex-start;
}
.controls-col-center {
justify-self: center;
width: auto;
align-items: center;
flex: 0 0 auto;
}
.controls-col-wfm.label-below-col .label {
justify-content: flex-start;
@@ -444,12 +524,15 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
align-self: stretch;
}
.controls-col .jog-container { align-self: center; }
/* Sized by the buttons that are actually there. Three fixed tracks meant a rig
without transmit or lock kept two empty ones, leaving its label chip stranded
at the far edge of the group. */
.btn-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.btn-grid button { width: 100%; height: var(--control-height); }
.btn-grid button { flex: 0 1 auto; min-width: 6rem; height: var(--control-height); }
.jog-container {
display: flex;
align-items: center;
@@ -570,6 +653,24 @@ input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem;
border-color: var(--accent-yellow);
color: var(--accent-yellow);
}
/* One row, read left to right: step through the entries, hand the rig back,
then — fenced off — the entry actually on air. */
.scheduler-action-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
/* The rule belongs to the current-entry block, so it leaves with it: the
block is display-toggled whenever fewer than two entries are active. */
.scheduler-action-row #scheduler-cycle-status::before {
content: "";
align-self: stretch;
width: 1px;
min-height: 1.7rem;
margin-inline: 0.3rem;
background: color-mix(in srgb, var(--border-light) 70%, transparent);
}
.scheduler-step-controls {
display: flex;
flex-wrap: wrap;
@@ -1229,12 +1330,20 @@ small { color: var(--text-muted); }
gap: 0;
flex-shrink: 0;
}
/* One row, always. Controls that do not fit are moved into the overflow menu
* by ui-core rather than wrapping: wrapping made the header's height depend on
* the viewport width in a way that was not even monotonic (112px at 1440,
* 169px at 1100, 131px at 900), which is what made the bar feel unstable. */
.top-bar-actions {
display: flex;
align-items: flex-start;
gap: 0.45rem 0.6rem;
align-items: center;
gap: 0.4rem;
min-width: 0;
flex-wrap: wrap;
flex-wrap: nowrap;
}
/* Every control in the bar is the same height and none of them stretch. */
.top-bar-actions > * {
flex: 0 0 auto;
}
.header-bar-btn.header-audio-btn {
width: 2rem;
@@ -1403,21 +1512,13 @@ small { color: var(--text-muted); }
gap: 0.8rem;
padding: 0.9rem 0.2rem 0;
}
/* Inline, not stacked: the summary used to sit under the select, making this
* the only 54px control in a bar of 32px ones. */
.header-rig-switch {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 0.35rem;
}
.header-rig-summary {
display: block;
max-width: 20rem;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.header-rig-switch select {
min-width: 8rem;
@@ -1531,21 +1632,75 @@ small { color: var(--text-muted); }
align-items: center;
gap: 0.2rem;
min-width: 0;
flex-wrap: wrap;
/* Never wraps. It must still be able to shrink below its content, or it
* overlaps the controls; the menus it anchors are reparented to the body on
* open, so clipping here no longer reaches them. */
flex-wrap: nowrap;
overflow: hidden;
}
.tab-bar-nav .tab { flex: 0 0 auto; }
/* Icons before scrolling: every tab already carries one, and four icons plus
* More always fit, so the strip never has to hide a destination. Applied by
* measurement (ui-core's reflowOverflow) rather than at a viewport width: how
* much fits depends on the rig name and on how wide the platform renders the
* labels, so the same width fits on one machine and clips on another.
* Bounded below 761px, where the strip becomes the bottom nav and keeps its
* labels under the icons. */
@media (min-width: 761px) {
.tab-bar-nav.nav-icons-only .tab .tab-label { display: none; }
.tab-bar-nav.nav-icons-only .tab .tab-icon,
.tab-bar-nav.nav-icons-only .tab .tab-more-icon { display: block; }
.tab-bar-nav.nav-icons-only .tab { padding: 0.45rem 0.55rem; }
}
/* Last resort, past icons: something in the bar has to absorb the shortfall,
and by default that is the tab strip — it may shrink below its content, so
its tabs keep full width and run under the controls, unreachable. The
identity block takes it instead, ellipsised: a clipped station name is still
readable, a destination hidden under the controls is not. */
.tab-bar.bar-tight .header-main {
flex-shrink: 1;
min-width: 0;
}
.tab-bar.bar-tight .header-text {
min-width: 0;
}
.tab-bar.bar-tight .title,
.tab-bar.bar-tight .subtitle {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-bar.bar-tight .tab-bar-nav {
flex-shrink: 0;
}
/* The selected destination is boxed, not underlined — the same treatment the
mobile bottom nav already used, so one navigation model reads the same at
every width. The transparent border is on the base so switching tabs moves
no neighbours. */
.tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 0.5rem 0.95rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
padding: 0.45rem 0.9rem;
color: var(--text-muted);
cursor: pointer;
font-size: 0.95rem;
height: auto;
transition: color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
.tab.active {
border-color: color-mix(in srgb, var(--accent-green) 50%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 12%, transparent);
color: var(--accent-text);
font-weight: 600;
}
.tab:hover:not(.active) {
color: var(--text);
background: color-mix(in srgb, var(--btn-bg) 55%, transparent);
border-color: color-mix(in srgb, var(--border-light) 40%, transparent);
}
.tab.active { border-bottom-color: var(--accent-green); color: var(--accent-text); font-weight: 600; }
.tab:hover:not(.active) { color: var(--text); }
/* Tab icons — hidden on desktop, shown on mobile bottom nav */
.tab-icon {
display: none;
@@ -1554,6 +1709,7 @@ small { color: var(--text-muted); }
flex-shrink: 0;
}
.tab-label { display: block; }
.tab-more-icon { display: none; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
.about-card-title { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-heading); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--card-bg) 50%, var(--bg)); }
@@ -1562,15 +1718,88 @@ small { color: var(--text-muted); }
.about-table td { padding: 0.4rem 0.75rem; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); font-size: 0.85rem; }
.about-table tr:last-child td { border-bottom: none; }
.about-table td:first-child { color: var(--text-muted); width: 40%; }
.about-status-on { color: #00d17f; }
.about-status-on { color: var(--status-ok); }
.about-status-off { color: var(--text-muted); }
.plugin-item { padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); color: var(--text); }
.plugin-item:last-child { border-bottom: none; }
.footer { display: flex; justify-content: space-between; align-items: baseline; margin-top: auto; padding-top: 1rem; flex-shrink: 0; }
/* ── Footer ───────────────────────────────────────────────────────────
A hairline rule closes the page the way .tab-bar opens it; the two
clusters (attribution + source pill, live status chip) sit on one
baseline-free centre line so the pills don't hang off the text. */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
flex-wrap: wrap;
margin-top: auto;
padding: var(--space-3) 0 var(--space-1);
border-top: 1px solid color-mix(in srgb, var(--border-light) 45%, transparent);
flex-shrink: 0;
}
.footer-meta {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
min-width: 0;
}
.full-row { grid-column: 1 / -1; }
.copyright { color: var(--text-muted); font-size: 0.75rem; opacity: 0.7; }
/* No opacity: --text-muted is already the muted step, and stacking the
two put the attribution below a readable contrast ratio. */
.copyright { color: var(--text-muted); font-size: var(--fs-xs); }
.copyright a { color: var(--accent-text); text-decoration: none; }
.copyright a:hover { text-decoration: underline; }
/* Live status: dot + text, colour driven by the data-state app.ts sets. */
.footer .hint {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-xs);
color: var(--text-heading);
padding: 0.2rem 0.6rem;
border-radius: var(--radius-pill);
border: 1px solid color-mix(in srgb, var(--border-light) 60%, transparent);
background: color-mix(in srgb, var(--btn-bg) 55%, transparent);
font-variant-numeric: tabular-nums;
}
.footer .hint::before {
content: "";
width: 0.45rem;
height: 0.45rem;
flex-shrink: 0;
border-radius: 50%;
background: var(--text-muted);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--text-muted) 22%, transparent);
transition: background var(--dur-base) var(--ease-standard),
box-shadow var(--dur-base) var(--ease-standard);
}
.footer .hint[data-state="ok"]::before {
background: var(--status-ok);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--status-ok) 24%, transparent);
}
.footer .hint[data-state="busy"]::before {
background: var(--accent-yellow);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--accent-yellow) 24%, transparent);
}
.footer .hint[data-state="error"] {
color: var(--accent-red);
border-color: color-mix(in srgb, var(--accent-red) 45%, var(--border-light));
}
.footer .hint[data-state="error"]::before {
background: var(--accent-red);
box-shadow: 0 0 0 0.16rem color-mix(in srgb, var(--accent-red) 24%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
.footer .hint[data-state="busy"]::before,
.footer .hint[data-state="error"]::before {
animation: trx-status-pulse 1.8s var(--ease-standard) infinite;
}
}
@keyframes trx-status-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
.gh-link-wrap {
display: inline-flex;
vertical-align: middle;
@@ -1579,6 +1808,8 @@ small { color: var(--text-muted); }
.gh-link {
display: inline-flex;
align-items: center;
text-decoration: none;
font-size: var(--fs-xs);
gap: 0.34rem;
padding: 0.18rem 0.5rem;
border-radius: 999px;
@@ -1614,6 +1845,182 @@ small { color: var(--text-muted); }
color: var(--text-muted);
line-height: 1;
}
/* Squelch: a threshold in dB, the unit the spectrum's own axis is labelled in,
with a dot showing whether the gate is passing audio right now. The old
percentage was a number with nothing on screen to relate it to. */
.sql-control {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--text-muted);
font-size: 0.82rem;
white-space: nowrap;
}
/* The row carries three unrelated things — how loud, whether there is audio at
all, and how much is arriving — so each is named and fenced from the next.
Each rule belongs to the block that follows it, so it leaves when that block
does (the squelch is absent on rigs without one). */
.audio-group {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.audio-volume-group {
flex: 0 1 auto;
}
.audio-level-group {
flex: 1 1 12rem;
min-width: 8rem;
}
.audio-group-label {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
}
.audio-level-group #audio-status {
min-width: 3.4rem;
}
.audio-level-group::before,
.sql-control::before {
content: "";
align-self: stretch;
width: 1px;
min-height: 1.5rem;
margin-inline: 0.5rem 0.6rem;
background: color-mix(in srgb, var(--border-light) 65%, transparent);
display: none;
}
/* Only while the row is one line. A rule divides what sits either side of it,
so once a group wraps onto its own line the wrap is the division and the
rule would just be a mark at the start of a line. Measured against the row,
not the viewport: what fits depends on whether the rig transmits and whether
it has a squelch at all. */
#audio-row .inline {
container-type: inline-size;
container-name: audio-row;
}
@container audio-row (min-width: 57rem) {
.audio-level-group::before,
.sql-control::before {
display: block;
}
}
/* The name is the switch: one target instead of a label and a button that
said the same thing twice, with the dot carrying the state. */
.sql-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 1.5rem;
min-height: 0;
padding: 0 0.4rem;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
}
.sql-toggle:hover {
border-color: color-mix(in srgb, var(--border-light) 60%, transparent);
color: var(--text);
}
.sql-toggle[aria-pressed="true"] {
border-color: color-mix(in srgb, var(--accent-green) 50%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
color: var(--accent-text);
}
.sql-state {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
background: color-mix(in srgb, var(--text-muted) 45%, transparent);
transition: background var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.sql-state[data-state="open"] {
background: var(--status-ok);
box-shadow: 0 0 0 0.14rem color-mix(in srgb, var(--status-ok) 25%, transparent);
}
.sql-state[data-state="closed"] {
background: var(--accent-yellow);
box-shadow: 0 0 0 0.14rem color-mix(in srgb, var(--accent-yellow) 22%, transparent);
}
.sql-db {
display: inline-flex;
align-items: baseline;
gap: 0.2rem;
}
.sql-db input {
width: 3.4rem;
height: 1.5rem;
min-height: 0;
padding: 0 0.3rem;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
text-align: right;
border: 1px solid var(--border-light);
border-radius: 4px;
background: var(--input-bg);
color: var(--text);
}
.sql-db-unit {
font-size: 0.68rem;
}
.sql-control button[aria-pressed="true"] {
border-color: color-mix(in srgb, var(--accent-green) 55%, var(--border-light));
color: var(--accent-text);
}
/* The line is the control: it spans the plot at its threshold and carries its
own grip, so the level is set where the noise it has to clear is visible. */
.spectrum-squelch-line {
position: absolute;
left: 0;
right: 0;
height: 0;
/* Above the overlays that share the plot: the split control sits at the
right edge on z-index 9 and would otherwise swallow the grip's pointer. */
z-index: 10;
border-top: 1px dashed color-mix(in srgb, var(--accent-yellow) 75%, transparent);
pointer-events: none;
}
.spectrum-squelch-line[data-state="open"] {
border-top-color: color-mix(in srgb, var(--status-ok) 80%, transparent);
}
.spectrum-squelch-grip {
position: absolute;
/* Clear of the split control's column so the two never sit on top of each
other, whatever height the threshold is at. */
right: 3.6rem;
top: -0.72rem;
padding: 0.05rem 0.35rem;
border-radius: 3px;
border: 1px solid color-mix(in srgb, var(--accent-yellow) 60%, var(--border-light));
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
color: var(--text);
font-size: 0.62rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.3;
cursor: ns-resize;
pointer-events: auto;
user-select: none;
touch-action: none;
}
.spectrum-squelch-line[data-state="open"] .spectrum-squelch-grip {
border-color: color-mix(in srgb, var(--status-ok) 60%, var(--border-light));
}
.spectrum-squelch-grip:focus-visible {
outline: 2px solid var(--accent-text);
outline-offset: 1px;
}
.sql-auto-btn {
font-size: 0.68rem;
padding: 0 5px;
@@ -1669,6 +2076,61 @@ small { color: var(--text-muted); }
}
.sub-tab-bar { display: flex; border-bottom: 1px solid var(--border); margin-bottom: 0.75rem; overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* ── Digital modes: sidebar + panel ───────────────────────────────────
Thirteen decoders in a horizontal strip meant a scroller on anything but a
wide window, and which one was open was a single underline in a row of
thirteen. As a list down the side every decoder is visible at once, each
with the state dot it already carried, and the panel gets the full width. */
#tab-digital-modes {
display: grid;
grid-template-columns: minmax(8.5rem, 11rem) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
#tab-digital-modes > .sub-tab-bar {
flex-direction: column;
align-items: stretch;
gap: 0.12rem;
margin-bottom: 0;
padding-right: 0.85rem;
border-bottom: none;
border-right: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
overflow-x: hidden;
/* Stays put while a long decode list scrolls past it. */
position: sticky;
top: 0.5rem;
}
#tab-digital-modes > .sub-tab-bar .sub-tab {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.42rem 0.6rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
text-align: left;
font-size: 0.88rem;
transition: color var(--dur-fast) var(--ease-standard),
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard);
}
#tab-digital-modes > .sub-tab-bar .sub-tab .decoder-state-dot {
margin-left: auto;
}
#tab-digital-modes > .sub-tab-bar .sub-tab:hover:not(.active) {
color: var(--text);
background: color-mix(in srgb, var(--btn-bg) 55%, transparent);
}
#tab-digital-modes > .sub-tab-bar .sub-tab.active {
border-color: color-mix(in srgb, var(--accent-green) 50%, var(--border-light));
background: color-mix(in srgb, var(--accent-green) 12%, transparent);
color: var(--accent-text);
}
#tab-digital-modes > .sub-tab-panel {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.sub-tab { flex-shrink: 0; background: transparent; border: none; border-bottom: 2px solid transparent; border-radius: 0; padding: 0.35rem 0.75rem; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; height: auto; }
.sub-tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
.sub-tab:hover:not(.active) { color: var(--text); }
@@ -1826,11 +2288,17 @@ small { color: var(--text-muted); }
font-size: 0.82rem;
color: var(--text-muted);
}
/* Full-bleed: break the map panel out of the centred .card column so the
stage spans the whole viewport width. The negative inline margins cancel
the card's centring offset plus its side padding, whatever they are. */
#tab-map {
display: flex;
flex-direction: column;
min-height: 0;
gap: 0.85rem;
width: 100vw;
max-width: 100vw;
margin-inline: calc(50% - 50vw);
}
/* map-loading uses the shared .decode-history-overlay .content-overlay classes */
#map-stage {
@@ -1838,8 +2306,9 @@ small { color: var(--text-muted); }
flex: 0 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 6px;
border-radius: 0;
border: 1px solid color-mix(in srgb, var(--border-light) 78%, transparent);
border-inline: 0;
background:
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 86%, transparent), color-mix(in srgb, var(--card-bg) 64%, transparent)),
color-mix(in srgb, var(--input-bg) 88%, transparent);
@@ -2520,7 +2989,21 @@ body.map-fake-fullscreen-active {
font-size: 0.75rem;
word-break: break-all;
}
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
/* APRS symbols are drawn from the vendored hessu/aprs-symbols sprite sheets:
16x6 grids of 24px cells, indexed by `symbol code - 0x21`. The cell offset
is set inline by the APRS/map bundles; the sheet URL stays here so retina
displays can swap in the @2x variants. */
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-repeat: no-repeat; background-size: 384px 144px; vertical-align: middle; margin-right: 0.3rem; }
.aprs-symbol-marker { margin-right: 0; }
.aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0.png'); }
.aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1.png'); }
.aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2.png'), url('/vendor/aprs-symbols-24-1.png'); }
@media (min-resolution: 2dppx) {
.aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0-2x.png'); }
.aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1-2x.png'); }
.aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2-2x.png'), url('/vendor/aprs-symbols-24-1-2x.png'); }
}
/* Fallback for symbol codes outside the sprite sheets: show the raw character. */
.aprs-symbol-local { border: 1px solid var(--border); border-radius: 4px; background: var(--surface-raised); color: var(--text); font: 700 0.8rem/22px ui-monospace, monospace; text-align: center; }
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
.aprs-pos:hover { text-decoration: underline; }
@@ -2964,7 +3447,10 @@ button.is-active {
text-transform: uppercase;
overflow: visible;
}
.operator-layout-picker::before { content: "Layout"; }
/* The prefix made the least-used control the widest in the bar; the
* select carries its own aria-label and title instead. */
.operator-layout-picker { padding-left: 0; }
.operator-layout-picker select { max-width: 8rem; }
.operator-layout-picker select {
width: auto;
min-height: 2rem;
@@ -3024,9 +3510,6 @@ body[data-operator-layout="broadcast"] #sam-controls-col,
body[data-operator-layout="broadcast"] #advanced-radio-controls {
display: none !important;
}
body[data-operator-layout="broadcast"] .controls-row {
grid-template-columns: minmax(8rem, 0.65fr) auto minmax(20rem, 2fr);
}
body[data-operator-layout="broadcast"] #wfm-controls-col,
body[data-operator-layout="broadcast"] #audio-row,
body[data-operator-layout="broadcast"] #spectrum-bw-row {
@@ -3057,12 +3540,24 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: none !important;
}
@media (max-width: 760px) {
body[data-operator-layout="broadcast"] .controls-row { grid-template-columns: 1fr auto; }
body[data-operator-layout="broadcast"] #wfm-controls-col { grid-column: 1 / -1; }
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
}
.mobile-more-btn, .mobile-more-menu, .decoder-tab-select { display: none; }
/* One navigation model at every width. Statistics, Recorder, Settings and
* About are occasional destinations: they live behind More rather than
* competing with the operating tabs for the row and then scrolling out of
* reach. The mobile layout already grouped them this way; the desktop strip
* now matches it, which is why the tab strip no longer needs to scroll. */
.tab-bar-nav .tab[data-tab="statistics"],
.tab-bar-nav .tab[data-tab="recorder"],
.tab-bar-nav .tab[data-tab="settings"],
.tab-bar-nav .tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; flex: 0 0 auto; }
.mobile-more-menu, .decoder-tab-select { display: none; }
.tab-bar-nav { position: relative; }
.mobile-more-menu {
position: fixed;
position: absolute;
top: calc(100% + 0.4rem);
right: 0;
right: max(0.75rem, env(safe-area-inset-right));
bottom: calc(5.4rem + env(safe-area-inset-bottom));
z-index: 80;
@@ -3170,9 +3665,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
height: 1rem;
cursor: ew-resize;
}
.controls-row { grid-template-columns: 1fr auto; }
.controls-col-wfm { grid-column: 1 / -1; }
.controls-col-power { grid-column: 1 / -1; }
.controls-col-wfm { flex-basis: 100%; }
.controls-col-power { flex-basis: 100%; }
.controls-col.label-below-col .inline,
.controls-col.label-below-col .btn-grid { margin-top: 0; }
.wfm-controls-inline { flex-wrap: wrap; }
@@ -3231,7 +3725,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
min-height: 3.2rem;
padding: 0.35rem 0.1rem 0.3rem;
border: 1px solid transparent;
border-bottom: none;
border-radius: 0.75rem;
white-space: nowrap;
text-align: center;
@@ -3251,7 +3744,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text);
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
}
.tab-icon { display: block; }
.tab-icon, .tab-more-icon { display: block; }
/* Shorten long tab labels to keep bottom nav compact */
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
@@ -3259,6 +3752,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
.mobile-more-btn { display: flex; }
.mobile-more-menu {
position: fixed;
top: auto;
right: max(0.75rem, env(safe-area-inset-right));
bottom: calc(5.4rem + env(safe-area-inset-bottom));
}
.mobile-more-btn[aria-expanded="true"] {
color: var(--accent-text);
background: color-mix(in srgb, var(--accent-green) 10%, transparent);
@@ -3278,28 +3777,36 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
font-weight: 600;
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 5%, transparent);
}
/* The picker replaces the sidebar here, so the panel takes the full width.
align-content keeps the two rows at their own height: the panel fills the
tab, and stretching would otherwise hand the picker a third of it. */
#tab-digital-modes {
grid-template-columns: minmax(0, 1fr);
align-content: start;
}
#tab-digital-modes > .sub-tab-bar { display: none; }
#tab-digital-modes > .sub-tab-panel { grid-column: 1; grid-row: auto; }
.toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); }
.top-bar-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.45rem;
justify-content: flex-end;
flex-wrap: nowrap;
gap: 0.4rem;
}
.operator-layout-picker { max-width: 100%; }
.operator-layout-picker select { max-width: 10rem; }
.header-rig-switch,
.header-style-pick {
flex: 1 1 12rem;
flex: 0 0 auto;
min-width: 0;
}
.header-rig-switch select,
.header-style-pick select {
width: 100%;
width: auto;
min-width: 0;
}
.header-bar-btn {
flex: 1 1 calc(50% - 0.3rem);
flex: 0 0 auto;
min-width: 0;
}
#tab-main,
@@ -3799,6 +4306,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
overflow: hidden;
border-bottom-color: var(--border-light, rgba(255,255,255,0.06));
}
/* No allocations in range. The height stays reserved — collapsing it moved
every element below by 18px each time tuning left a band. */
#spectrum-bandplan-strip.bp-empty {
background: color-mix(in srgb, var(--border) 22%, transparent);
}
.bp-segment {
position: absolute;
top: 0;
@@ -3889,6 +4401,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
height: 38px;
overflow: visible;
}
/* The rail sits over the top of the overview plot, so it must not swallow
drags and clicks meant for it — only the chips themselves are targets. */
#spectrum-bookmark-axis { pointer-events: none; }
#spectrum-bookmark-axis .spectrum-bookmark-chip { pointer-events: auto; }
/* Nothing bookmarked in range: the rail keeps its place but stays blank —
no caption over the plot. */
.spectrum-bookmark-chip {
position: absolute;
top: 2px;
@@ -4137,11 +4655,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
@media (max-width: 520px) {
/* Single-column controls: jog first, then mode, then power */
.controls-row {
grid-template-columns: 1fr;
flex-direction: column;
align-items: stretch;
}
.controls-col-center {
order: -1;
justify-self: center;
align-self: center;
width: auto;
}
.controls-col-center::after { display: none; }
@@ -4153,6 +4672,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
/* Wider volume sliders for touch */
.vol-slider { width: 100%; flex: 1 1 auto; }
.audio-group {
flex-wrap: wrap;
width: 100%;
}
.audio-group-label { width: 100%; }
.vol-label {
flex-direction: row;
align-items: center;
@@ -4172,8 +4696,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
}
#rx-audio-btn,
#tx-audio-btn,
#audio-level,
#audio-status,
.audio-volume-group,
.audio-level-group,
#sdr-squelch-wrap {
grid-column: 1 / -1;
}
@@ -5153,7 +5677,6 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.stats-controls {
display: flex;
@@ -5379,3 +5902,51 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
font-size: max(0.75rem, 12px);
}
}
/* --- Top bar overflow menu ------------------------------------------------ */
.top-bar-more { position: relative; }
.top-bar-more[hidden] { display: none; }
.top-bar-more-btn {
width: 2rem;
height: 2rem;
min-height: 2rem;
padding: 0;
font-size: 1rem;
line-height: 1;
}
.top-bar-more-menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 40;
display: none;
flex-direction: column;
gap: 0.4rem;
min-width: 12rem;
padding: 0.5rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
}
.top-bar-more-menu.is-open { display: flex; }
.top-bar-more-menu > * { width: 100%; }
.top-bar-more-menu select { width: 100%; max-width: none; }
/* Page title for destinations reached through the Tools menu. The tab strip
* cannot show which of them is active, so each states its own name.
*
* The margin is the spacing on the plain block panels; #tab-statistics is a
* flex column with its own `gap`, where the margin lands on top of that gap
* and doubles the space under the title. */
.section-heading {
margin: 0 0 var(--space-3);
font-size: 1.15rem;
font-weight: 700;
line-height: 1.2;
color: var(--text);
}
#tab-statistics > .section-heading { margin-bottom: 0; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -12,7 +12,7 @@
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
"test": "node --test tests/*.test.mjs",
"test:browser": "node tests/browser-smoke.mjs",
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
@@ -42,6 +42,9 @@ source change.
- `core/` contains dependency-light shared helpers.
- `features/` contains application behavior grouped by responsibility.
- `plugins/` contains lazy decoder and high-coupling feature entries.
- `plugins/host.ts` declares the typed `window.trx` state and services that
feature bundles read; feature entries import it rather than reaching for bare
`window` properties, which the module graph does not publish.
- Files under `assets/web/vendor/` are vendored JavaScript and are not part of
the TypeScript migration.
@@ -15,6 +15,7 @@ import {
applyDecoderRegistryVisibility,
decoderRegistry,
loadDecoderRegistry,
onDecoderRegistryReady,
} from "./core/decoder-registry.js";
import {
fetchAuthSession,
@@ -171,6 +172,8 @@ interface TrxModules {
map?: {
aprsMap?: { invalidateSize(): void } | null;
initAprsMap(): void;
focusMapPosition?(lat: number, lon: number): void;
focusMapLocator?(grid: string, preferredType?: string | null): boolean;
sizeAprsMapToViewport(): void;
pruneMapHistory(): void;
updateMapBaseLayerForTheme(theme: string): void;
@@ -201,6 +204,8 @@ interface TrxModules {
handleChannels(data: string): void;
handleSession(data: string): void;
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
interceptFrequency(frequencyHz: number): boolean;
interceptFreqDisplay(): boolean;
interceptMode(mode: string): Promise<boolean>;
isOnVirtual(): boolean;
takeSchedulerControl(): Promise<void>;
@@ -217,6 +222,7 @@ interface TrxState {
readonly lastRigDisplayNames: Record<string, string>;
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authEnabled: boolean;
readonly authRole: AuthRole | null;
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
@@ -233,7 +239,10 @@ interface TrxState {
readonly aisVesselUrlBase: string | null;
readonly serverRigs: RigListItem[];
readonly serverActiveRigId: string | null;
readonly lastModeName: string;
lastModeName: string;
readonly jogUnit: number;
readonly rxActive: boolean;
audioChannelOverride: string | null;
readonly lastSpectrumData: SpectrumFrame | null;
readonly lastSpectrumRenderData: SpectrumFrame | null;
currentBandwidthHz: number;
@@ -352,6 +361,10 @@ declare global {
trx: { state: TrxState; core: Readonly<Record<string, unknown>>; modules: TrxModules };
trxUi: TrxUi;
trxPluginRuntime: TrxPluginRuntime;
// Owned here, not by the lazy map module: decode rows link to the map long
// before it has been loaded.
navigateToAprsMap(lat: number, lon: number): void;
navigateToMapLocator(grid: string, preferredType?: string | null): void;
lastSpectrumData: SpectrumFrame | null;
lastFreqHz: number | null;
currentBandwidthHz: number;
@@ -470,6 +483,8 @@ function hideAuthGate() {
});
navigateToTab(tabFromPath(), { updateHistory: false, replaceHistory: true });
syncTopBarAccess();
// The startup fetch may have run before there was a session to authorise it.
if (!bandplanData) void loadBandplanJson();
}
function showAuthError(msg: string) {
@@ -717,7 +732,6 @@ const signalSplitValueEl = document.getElementById("signal-split-value");
const overviewPeakHoldEl = document.getElementById("overview-peak-hold") as HTMLInputElement | null;
const themeToggleBtn = document.getElementById("theme-toggle") as HTMLButtonElement | null;
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select") as HTMLSelectElement | null;
const headerRigSummary = document.getElementById("header-rig-summary");
const headerStylePickSelect = document.getElementById("header-style-pick-select") as HTMLSelectElement | null;
const rdsPsOverlay = document.getElementById("rds-ps-overlay");
const tabMainEl = document.getElementById("tab-main");
@@ -938,6 +952,8 @@ function formatSigStrength(dbm: number | null) {
}
function refreshSigStrengthDisplay() {
// The squelch indicator reads the same meter, so it follows it here.
renderSdrSquelch();
if (!sigStrengthEl) return;
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
}
@@ -997,6 +1013,7 @@ async function restorePreviousTuneState() {
savePreviousTuneState(); // save current as previous so B toggles back
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
modeEl.value = saved.mode;
syncModePicker();
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
updateWfmControls();
}
@@ -1326,6 +1343,22 @@ function readyText() {
return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
// The footer hint is a status pill whose dot is coloured from data-state,
// so the text has to be written through setPowerHint rather than assigned.
const HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
const HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
function hintState(msg: string): "ok" | "busy" | "error" {
if (HINT_ERROR_RE.test(msg)) return "error";
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
return "ok";
}
function setPowerHint(msg: string) {
powerHint.textContent = msg;
powerHint.dataset.state = hintState(msg);
}
function rigBadgeColor(rigId: string) {
const text = (rigId || "rx").toString();
let hash = 0;
@@ -1364,20 +1397,6 @@ function populateRigPicker(selectEl: HTMLSelectElement | null, rigIds: string[],
selectEl.disabled = disabled;
}
function updateRigIdentitySummary(rigId: string | null, pending = false) {
if (!headerRigSummary) return;
const rig = serverRigs.find((entry) => entry?.remote === rigId);
if (!rig) {
headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available";
return;
}
const hardware = [rig.manufacturer, rig.model].map(value => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote;
const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [];
const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)];
if (modes.length > 5) features.push(`+${modes.length - 5} modes`);
headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`;
}
function updateRigSubtitle(activeRigId: string | null) {
if (!rigSubtitle) return;
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
@@ -1414,7 +1433,6 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
if (rigListChanged) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
@@ -1470,10 +1488,10 @@ function refreshOperatorLayoutCapabilities() {
}
function showHint(msg: string, duration?: number) {
powerHint.textContent = msg;
setPowerHint(msg);
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) {
if (duration) hintTimer = setTimeout(() => { setPowerHint(readyText()); }, duration);
if (HINT_ERROR_RE.test(msg)) {
window.trxUi?.notify(msg, { kind: "error" });
}
}
@@ -1984,6 +2002,9 @@ function refreshWavelengthDisplay(hz: number) {
}
function refreshFreqDisplay() {
// On a virtual channel the field must show that channel's frequency; SSE
// rig-state updates would otherwise overwrite it with the primary channel.
if (window.trx?.modules.vchan?.interceptFreqDisplay()) return;
if (lastFreqHz == null || freqDirty) return;
freqEl.value = formatFreqForStep(lastFreqHz, jogUnit);
refreshWavelengthDisplay(lastFreqHz);
@@ -2476,12 +2497,26 @@ async function ensureTunedBandwidthCoverage(freqHz: number, bandwidthHz = covera
let _freqOptimisticHz: number | null = null;
let _freqOptimisticSeq = 0;
// Lazy features paint their own optimistic frequency before starting the tune
// round-trip (a bookmark updates the marker before mode and bandwidth settle).
// Arming the guard from there keeps SSE from snapping back to the stale value.
function armOptimisticFrequency(freqHz: number) {
if (!isFiniteNumber(freqHz)) return;
_freqOptimisticSeq += 1;
_freqOptimisticHz = Math.round(freqHz);
}
function setRigFrequency(freqHz: number) {
const targetHz = Math.round(freqHz);
if (!freqAllowed(targetHz)) {
showUnsupportedFreqPopup(targetHz);
throw new Error(`Unsupported frequency: ${targetHz}`);
}
// A virtual channel owns its own tuning path: the plugin applies the
// optimistic update and posts to the channel API instead of the rig.
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
// Scheduler control is fire-and-forget — don't block the freq change.
void window.trx?.modules.vchan?.takeSchedulerControl();
// Optimistic local update — visual is instant via CSS overlay + guard.
const prevFreqHz = lastFreqHz;
const seq = ++_freqOptimisticSeq;
@@ -2939,6 +2974,7 @@ function setDisabled(disabled: boolean) {
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
syncModePicker();
}
let serverVersion: string | null = null;
@@ -3284,13 +3320,13 @@ function render(update: AppUpdate) {
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
powerHint.textContent = "Initializing rig…";
setPowerHint("Initializing rig…");
setDisabled(true);
return;
}
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
powerHint.textContent = "Rig not fully initialized yet";
setPowerHint("Rig not fully initialized yet");
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
@@ -3328,6 +3364,7 @@ function render(update: AppUpdate) {
opt.textContent = m;
modeEl.appendChild(opt);
});
renderModePicker();
}
}
if (update.info && update.info.capabilities) {
@@ -3473,6 +3510,7 @@ function render(update: AppUpdate) {
// vchan.js will apply the correct mode via vchanSyncModeDisplay().
if (!onVirtual) {
modeEl.value = modeUpper;
syncModePicker();
if (modeUpper === "WFM" && lastModeName !== "WFM") {
setJogDivisor(10);
resetRdsDisplay();
@@ -3615,6 +3653,7 @@ function render(update: AppUpdate) {
const sUnits = dbmToSUnits(update.status.rx.sig);
sigLastSUnits = sUnits;
sigLastDbm = update.status.rx.sig;
recordSquelchMeterSample(update.status.rx.sig);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
signalBar.style.width = `${pct}%`;
signalValue.innerHTML = formatSignal(sUnits);
@@ -3641,7 +3680,7 @@ function render(update: AppUpdate) {
powerBtn.disabled = true;
powerBtn.textContent = "Power unavailable";
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
setPowerHint("State unknown");
}
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
@@ -3752,7 +3791,7 @@ function render(update: AppUpdate) {
if (Array.isArray(update.remotes)) {
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
}
powerHint.textContent = readyText();
setPowerHint(readyText());
lastLocked = update.status?.lock === true;
window.trxUi?.setButtonState(lockBtn, {
active: lastLocked,
@@ -3839,11 +3878,11 @@ function connect() {
render(data);
lastEventAt = Date.now();
if (data.server_connected === false) {
powerHint.textContent = "trx-server connection lost";
setPowerHint("trx-server connection lost");
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
} else {
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (data.initialized) powerHint.textContent = readyText();
if (data.initialized) setPowerHint(readyText());
}
} catch (e) {
console.error("Bad event data", e);
@@ -3866,7 +3905,7 @@ function connect() {
source.onerror = () => {
// Check if this is an auth error by looking at readyState
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setPowerHint("trx-client connection lost, retrying\u2026");
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -3877,7 +3916,7 @@ function connect() {
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 15000) {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setPowerHint("trx-client connection lost, retrying\u2026");
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
void pollFreshSnapshot();
@@ -3999,7 +4038,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
rigSwitchInProgress = true;
setControlPending(selectEl, true);
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
updateRigIdentitySummary(nextRig, true);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}`);
try {
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
@@ -4007,7 +4046,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
lastActiveRigId = nextRig;
resetDecoderStateOnRigSwitch();
updateRigSubtitle(lastActiveRigId);
updateRigIdentitySummary(lastActiveRigId);
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
@@ -4026,7 +4064,6 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
} catch (err) {
console.error("select_rig failed:", err);
selectEl.value = prevRig || "";
updateRigIdentitySummary(prevRig);
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
} finally {
rigSwitchInProgress = false;
@@ -4309,6 +4346,39 @@ if (jogMultEl) {
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
}
// The mode buttons are a view of the <select>, which stays the value everything
// else reads. Rebuilt when the rig's mode list changes, re-synced whenever
// anything writes to the select — including the plugins, via trxCore.
const modePickerEl = requiredElement("mode-picker");
function renderModePicker() {
modePickerEl.replaceChildren();
for (const option of Array.from(modeEl.options)) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.mode = option.value;
btn.textContent = option.textContent;
btn.addEventListener("click", () => {
if (btn.disabled || modeEl.value === option.value) return;
modeEl.value = option.value;
syncModePicker();
void applyModeFromPicker();
});
modePickerEl.appendChild(btn);
}
syncModePicker();
}
function syncModePicker() {
const active = (modeEl.value || "").toUpperCase();
modePickerEl.querySelectorAll<HTMLButtonElement>("button").forEach((btn) => {
const selected = (btn.dataset.mode || "").toUpperCase() === active;
btn.classList.toggle("active", selected);
btn.disabled = modeEl.disabled;
btn.setAttribute("aria-pressed", String(selected));
});
}
async function applyModeFromPicker() {
const mode = modeEl.value || "";
if (!mode) {
@@ -4317,6 +4387,7 @@ async function applyModeFromPicker() {
}
updateWfmControls();
setControlPending(modeEl, true);
syncModePicker();
showHint("Setting mode…");
try {
if (await window.trx.modules.vchan?.interceptMode(mode)) {
@@ -4335,6 +4406,7 @@ async function applyModeFromPicker() {
console.error(err);
} finally {
setControlPending(modeEl, false);
syncModePicker();
}
}
@@ -4545,6 +4617,54 @@ function tabFromPath(pathname = window.location.pathname) {
return tabFromPathname(pathname);
}
// Map links fire from decode rows — an AIS position, an APRS frame, an FT8
// grid — that exist long before the Map tab has ever been opened, and the map
// module is lazy. It used to install these two globals itself, so until
// something had opened the tab the AIS links threw ("not a function") and the
// APRS ones silently did nothing. The app owns them instead: it is the only
// place that can materialise the panel, load the module and update history,
// and the target waits here until the module is up.
type PendingMapTarget =
| { kind: "position"; lat: number; lon: number }
| { kind: "locator"; grid: string; preferredType: string | null };
let pendingMapTarget: PendingMapTarget | null = null;
function applyMapTarget(target: PendingMapTarget): boolean {
const map = window.trx.modules.map;
if (!map) return false;
if (target.kind === "position") {
map.focusMapPosition?.(target.lat, target.lon);
} else {
map.focusMapLocator?.(target.grid, target.preferredType);
}
return true;
}
function requestMapTarget(target: PendingMapTarget) {
pendingMapTarget = target;
navigateToTab("map");
// Already loaded: the tab switch has shown the panel, so focus can happen
// now. Otherwise _initMapWhenReady drains it once the module arrives.
if (window.trx.modules.map) {
requestAnimationFrame(() => { drainPendingMapTarget(); });
}
}
function drainPendingMapTarget() {
const target = pendingMapTarget;
if (!target) return;
if (applyMapTarget(target)) pendingMapTarget = null;
}
window.navigateToAprsMap = (lat: number, lon: number) => {
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
requestMapTarget({ kind: "position", lat, lon });
};
window.navigateToMapLocator = (grid: string, preferredType: string | null = null) => {
if (!grid) return;
requestMapTarget({ kind: "locator", grid, preferredType });
};
// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js
// (window.trx.modules.map) if they haven't loaded yet.
let _mapInitTimer: ReturnType<typeof setInterval> | null = null;
@@ -4564,6 +4684,7 @@ function _initMapWhenReady() {
requestAnimationFrame(() => {
map.sizeAprsMapToViewport();
map.aprsMap?.invalidateSize();
drainPendingMapTarget();
});
});
return;
@@ -4594,6 +4715,19 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
_activeTab = name;
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
// A destination the strip hides is reached through Tools, so mark that
// button instead — otherwise the strip looks identical on all four of them.
// Membership of the Tools menu is the test: it still reads from the grouping
// ui-core installs rather than a second copy of it, but unlike the tab's
// computed display it does not depend on anything being laid out. The first
// route navigation runs while the card is still behind the loading state,
// where every tab computes to display:none — which lit Tools up on every
// refresh of every page.
const toolsBtn = document.getElementById("mobile-more-btn");
if (toolsBtn) {
const inToolsMenu = !!document.querySelector(`#mobile-more-menu [data-navigate-tab="${name}"]`);
toolsBtn.classList.toggle("active", inToolsMenu);
}
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => p.style.display = "none");
const panel = document.getElementById(`tab-${name}`);
@@ -4822,6 +4956,7 @@ Object.defineProperties(trxState, {
lastRigDisplayNames: { get() { return lastRigDisplayNames; } },
initialMapZoom: { get() { return initialMapZoom; } },
decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
authEnabled: { get() { return authEnabled; } },
authRole: { get() { return authRole; } },
decoderRegistry: { get() { return decoderRegistry; } },
sseSessionId: { get() { return sseSessionId; } },
@@ -4838,7 +4973,10 @@ Object.defineProperties(trxState, {
aisVesselUrlBase: { get() { return aisVesselUrlBase; } },
serverRigs: { get() { return serverRigs; } },
serverActiveRigId: { get() { return serverActiveRigId; } },
lastModeName: { get() { return lastModeName; } },
lastModeName: { get() { return lastModeName; }, set(v: string) { lastModeName = v; } },
jogUnit: { get() { return jogUnit; } },
rxActive: { get() { return rxActive; } },
audioChannelOverride: { get() { return _audioChannelOverride; }, set(v: string | null) { _audioChannelOverride = v; } },
lastSpectrumData: { get() { return lastSpectrumData; } },
lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } },
currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v: number) { currentBandwidthHz = v; window.currentBandwidthHz = v; } },
@@ -4855,6 +4993,11 @@ const trxCore = Object.freeze({
saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency,
syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, syncModePicker,
formatFreqForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode,
resetRdsDisplay, positionRdsPsOverlay, updateWfmControls,
updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio,
latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm,
formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText,
nearestBookmarkForHz, currentDecodeHistoryRetentionMs,
@@ -5062,14 +5205,18 @@ const wfmCciValEl = document.getElementById("wfm-cci-val");
const wfmAciFillEl = document.getElementById("wfm-aci-fill");
const wfmAciValEl = document.getElementById("wfm-aci-val");
const samControlsCol = document.getElementById("sam-controls-col");
const modeControlsRow = document.getElementById("mode-controls-row");
const samStereoWidthEl = document.getElementById("sam-stereo-width") as HTMLInputElement | null;
const samCarrierSyncEl = document.getElementById("sam-carrier-sync") as HTMLSelectElement | null;
const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
const sdrSquelchEl = document.getElementById("sdr-squelch") as HTMLInputElement | null;
const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
const sdrSquelchDbEl = document.getElementById("sdr-squelch-db") as HTMLInputElement | null;
const sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
const sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle") as HTMLButtonElement | null;
const squelchLineEl = document.getElementById("spectrum-squelch-line");
const squelchGripEl = document.getElementById("spectrum-squelch-grip");
const squelchLabelEl = document.getElementById("spectrum-squelch-label");
const SDR_SQUELCH_MIN_DB = -120;
const SDR_SQUELCH_MAX_DB = -30;
let syncFromServerSdrSquelch = false;
const sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled") as HTMLInputElement | null;
const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
@@ -5169,97 +5316,240 @@ function normalizeWfmDenoiseLevel(value: unknown) {
return "auto";
}
function clampSdrSquelchPercent(value: number) {
if (!isFiniteNumber(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
// The threshold is held in dB, the scale the spectrum axis and the S-meter are
// labelled in and the one the server compares against. It used to be a
// percentage over that range, which left the operator no way to relate the
// control to anything on screen — and 0% doubled as "disabled", so turning the
// squelch off to listen threw the setting away.
let sdrSquelchEnabled: boolean = loadSetting("sdrSquelchEnabled", false);
let sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
function clampSdrSquelchDb(value: number) {
if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
}
function sdrSquelchPercentToServer(percent: number) {
const pct = clampSdrSquelchPercent(percent);
if (pct <= 0) {
return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
/** Passing right now, as far as the meter can tell: the same comparison the
* DSP makes, against the same number it reports. */
function sdrSquelchIsPassing() {
if (!sdrSquelchEnabled) return true;
if (!isFiniteNumber(sigLastDbm)) return false;
return sigLastDbm >= sdrSquelchThresholdDb;
}
function renderSdrSquelch() {
if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
}
const ratio = pct / 100;
const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return { enabled: true, thresholdDb };
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
}
const state = !sdrSquelchEnabled ? "off" : (sdrSquelchIsPassing() ? "open" : "closed");
if (sdrSquelchStateEl) {
sdrSquelchStateEl.dataset.state = state;
sdrSquelchStateEl.parentElement?.setAttribute(
"aria-label",
state === "off" ? "Squelch off" : (state === "open" ? "Squelch on, open" : "Squelch on, closed"),
);
}
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
positionSquelchLine();
}
function sdrSquelchServerToPercent(enabled: boolean, thresholdDb: number | null) {
if (!enabled) return 0;
if (!isFiniteNumber(thresholdDb)) return 0;
const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
return clampSdrSquelchPercent(ratio * 100);
/** Places the line at its threshold on the spectrum's dB axis. */
function positionSquelchLine() {
if (!squelchLineEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
const canvas = document.getElementById("spectrum-canvas");
const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM"
&& !!canvas && canvas.clientHeight > 0
&& getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
if (!visible) {
squelchLineEl.style.display = "none";
return;
}
const dbMin = spectrumFloor;
const dbMax = spectrumFloor + spectrumRange;
// Pinned to the plot edge when the threshold sits outside the visible dB
// window rather than hidden: the grip is how it gets dragged back, and the
// label still reads the real value.
const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
squelchLineEl.style.display = "";
squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
}
function updateSdrSquelchPctLabel() {
if (!sdrSquelchEl || !sdrSquelchPctEl) return;
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
function submitSdrSquelch() {
if (!sdrSquelchSupported) return;
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
postPath(
`/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}`
+ `&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`,
).catch(() => {});
}
function setSdrSquelch(thresholdDb: number, enabled: boolean, options: { submit?: boolean } = {}) {
sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
sdrSquelchEnabled = enabled;
renderSdrSquelch();
if (options.submit !== false) submitSdrSquelch();
}
// Auto reads the meter, not the spectrum. The threshold is compared against
// the channel level the meter reports, and that sits a long way from the
// spectrum's per-bin noise floor — the gap is set by the FFT size and window,
// the channel bandwidth, the decimation and peak-versus-mean statistics.
// Measured across ordinary configurations it ranges from -1 dB to +22 dB, so
// the old "noise floor + 6 dB" left the gate 16-22 dB below the noise on a
// narrow span and it simply never closed. Reading the same number the DSP
// compares needs no conversion at all.
const SQUELCH_NOISE_WINDOW_MS = 10_000;
const SQUELCH_NOISE_MARGIN_DB = 5;
const SQUELCH_MEASURE_MS = 1_500;
const squelchMeterSamples: SignalSample[] = [];
function recordSquelchMeterSample(db: number) {
if (!isFiniteNumber(db)) return;
const now = Date.now();
squelchMeterSamples.push({ t: now, v: db });
while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) {
squelchMeterSamples.shift();
}
}
/** The level the channel rests at, taken low enough down the distribution that
* a burst of traffic inside the window cannot drag it up. */
function squelchNoiseFloorDb(): number | null {
const now = Date.now();
const values = squelchMeterSamples
.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS)
.map((sample) => sample.v)
.sort((a, b) => a - b);
if (values.length < 4) return null;
return values[Math.floor((values.length - 1) * 0.2)] ?? null;
}
/** Just clear of the noise the meter is actually reading. */
function autoSquelchThresholdDb(): number | null {
const noiseDb = squelchNoiseFloorDb();
if (noiseDb == null) return null;
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
}
function updateSdrSquelchControlVisibility() {
if (!sdrSquelchWrapEl) return;
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
renderSdrSquelch();
}
function syncSdrSquelchFromServer(enabled: boolean, thresholdDb: number | null) {
if (!sdrSquelchEl) return;
if (document.activeElement === sdrSquelchEl) return;
const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
syncFromServerSdrSquelch = true;
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
syncFromServerSdrSquelch = false;
saveSetting("sdrSquelchPct", pct);
// Not while the operator is on the control: dragging the line or typing a
// level would fight the echo of the value the server last confirmed.
if (squelchDragPointerId !== null) return;
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
sdrSquelchEnabled = enabled;
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
renderSdrSquelch();
}
function submitSdrSquelchPercent(percent: number) {
if (!sdrSquelchSupported) return;
const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
postPath(
`/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`,
).catch(() => {});
}
let squelchDragPointerId: number | null = null;
let squelchDragSubmitAt = 0;
if (sdrSquelchEl) {
const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
sdrSquelchEl.value = String(savedPct);
updateSdrSquelchPctLabel();
sdrSquelchEl.addEventListener("input", () => {
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
if (!syncFromServerSdrSquelch) {
submitSdrSquelchPercent(pct);
}
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("change", () => {
setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
});
sdrSquelchDbEl.addEventListener("blur", () => { renderSdrSquelch(); });
}
if (sdrSquelchToggleBtn) {
sdrSquelchToggleBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
});
}
function applyAutoSquelch(threshold: number) {
setSdrSquelch(threshold, true);
showHint(`Squelch ${threshold} dB`, 1500);
}
const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto") as HTMLButtonElement | null;
if (sdrSquelchAutoBtn) {
sdrSquelchAutoBtn.addEventListener("click", () => {
if (!sdrSquelchSupported) return;
let pct = 0; // default: Off
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
// Set threshold slightly above noise floor so squelch closes on noise
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
pct = clampSdrSquelchPercent(
((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
);
const threshold = autoSquelchThresholdDb();
if (threshold != null) {
applyAutoSquelch(threshold);
return;
}
// Nothing recorded yet — right after a connection or a rig switch. Listen
// for a moment rather than refusing, which is what a radio does.
sdrSquelchAutoBtn.disabled = true;
showHint("Measuring the noise…");
setTimeout(() => {
sdrSquelchAutoBtn.disabled = false;
const measured = autoSquelchThresholdDb();
if (measured == null) {
showHint("No meter to measure the noise from", 1800);
return;
}
applyAutoSquelch(measured);
}, SQUELCH_MEASURE_MS);
});
}
// Dragging the line, the same gesture as the bandwidth edges. Submits are
// throttled so the gate follows the drag by ear without a request per pixel.
if (squelchGripEl) {
const dbFromClientY = (clientY: number) => {
const canvas = document.getElementById("spectrum-canvas");
if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
const rect = canvas.getBoundingClientRect();
const frac = 1 - (clientY - rect.top) / rect.height;
return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
};
squelchGripEl.addEventListener("pointerdown", (event) => {
if (!sdrSquelchSupported) return;
squelchDragPointerId = event.pointerId;
squelchGripEl.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
});
squelchGripEl.addEventListener("pointermove", (event) => {
if (squelchDragPointerId !== event.pointerId) return;
sdrSquelchThresholdDb = dbFromClientY(event.clientY);
renderSdrSquelch();
const now = Date.now();
if (now - squelchDragSubmitAt > 200) {
squelchDragSubmitAt = now;
submitSdrSquelch();
}
if (sdrSquelchEl) {
sdrSquelchEl.value = String(pct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", pct);
});
const endDrag = (event: PointerEvent) => {
if (squelchDragPointerId !== event.pointerId) return;
squelchDragPointerId = null;
submitSdrSquelch();
};
squelchGripEl.addEventListener("pointerup", endDrag);
squelchGripEl.addEventListener("pointercancel", endDrag);
squelchGripEl.addEventListener("keydown", (event) => {
const step = event.shiftKey ? 10 : 1;
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
} else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
} else {
return;
}
submitSdrSquelchPercent(pct);
event.preventDefault();
});
}
@@ -5380,6 +5670,9 @@ function updateWfmControls() {
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
// The row holds only these two, so it goes with them — an empty one would
// still take a track and a gap in the tray, and draw its divider.
if (modeControlsRow) modeControlsRow.style.display = (mode === "WFM" || mode === "SAM") ? "" : "none";
}
// Show compatibility warning for non-Chromium browsers
@@ -5475,8 +5768,9 @@ function extractAudioFrameChannels(frame: AudioData) {
return out;
}
// Optional channel_id injected by vchan.js when connecting to a virtual channel.
const _audioChannelOverride: string | null = null;
// Optional channel_id set by the vchan feature when a virtual channel is
// active, so RX audio subscribes to that channel instead of the primary one.
let _audioChannelOverride: string | null = null;
/** Schedule decoded PCM channels for playback via Web Audio API. */
function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) {
@@ -6086,15 +6380,10 @@ function volWheel(slider: HTMLInputElement, pctEl: HTMLElement, getGain: () => G
}
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
if (sdrSquelchEl) {
sdrSquelchEl.addEventListener("wheel", (e) => {
e.preventDefault();
const step = e.deltaY < 0 ? 2 : -2;
const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
sdrSquelchEl.value = String(next);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", next);
submitSdrSquelchPercent(next);
if (sdrSquelchDbEl) {
sdrSquelchDbEl.addEventListener("wheel", (event) => {
event.preventDefault();
setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
}, { passive: false });
}
@@ -6344,7 +6633,12 @@ function connectDecode() {
}
if (data.type === "group") {
const messages = Array.isArray(data.messages)
? data.messages.filter((message): message is DecodeMessage => isRecord(message) && typeof message.type === "string")
// Stored records carry only decoder fields — an AIS entry has mmsi,
// lat, lon and so on, and no `type`. That field identifies live SSE
// frames; history is already grouped, and the group's kind is
// delivered alongside these messages, so requiring it here discarded
// every restored record silently.
? data.messages.filter((message): message is DecodeMessage => isRecord(message))
: [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
@@ -6957,6 +7251,7 @@ function flushMeterDom() {
const sUnits = dbmToSUnits(dbm);
sigLastSUnits = sUnits;
sigLastDbm = dbm;
recordSquelchMeterSample(dbm);
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
if (signalBar) signalBar.style.width = `${pct}%`;
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
@@ -7303,6 +7598,9 @@ function drawSpectrum(data: SpectrumFrame) {
}
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
// The squelch line rides the same axis: reposition it whenever the axis or
// the plot geometry moves under it.
positionSquelchLine();
function hzToX(hz: number) {
return ((hz - range.visLoHz) / range.visSpanHz) * W;
@@ -7711,7 +8009,12 @@ function updateBookmarkAxis(range: SpectrumRange) {
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
const hasVisible = visBookmarks.length > 0;
axisEl.classList.toggle("bm-axis-visible", hasVisible);
// The rail is kept up with nothing in range — blank, no caption — so tuning
// across bands only swaps its contents rather than taking the strip itself
// away. This function only runs with a spectrum range in hand, so rigs
// without a spectrum never get it.
axisEl.classList.add("bm-axis-visible");
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
if (!hasVisible) {
if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; }
@@ -8025,35 +8328,21 @@ window.addEventListener("keydown", (event) => {
return;
}
// Q — toggle squelch (cycle 0 → auto → 0)
// Q — gate on or off, keeping the threshold. Picks one off the noise floor
// the first time, when there is nothing to keep.
if (key === "q") {
event.preventDefault();
if (sdrSquelchSupported && sdrSquelchEl) {
const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
let nextPct;
if (current > 0) {
nextPct = 0; // turn off
if (sdrSquelchSupported) {
if (sdrSquelchEnabled) {
setSdrSquelch(sdrSquelchThresholdDb, false);
showHint("Squelch off", 1200);
} else {
// Auto: estimate from noise floor
let auto = 30;
const data = lastSpectrumData || window.lastSpectrumData;
if (data && isBinsArray(data.bins) && data.bins.length > 0) {
const noiseDb = estimateNoiseFloorDb(data.bins);
if (noiseDb != null && isFiniteNumber(noiseDb)) {
const thresholdDb = noiseDb + 6;
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
auto = clampSdrSquelchPercent(
((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
);
}
}
nextPct = auto;
const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB
? sdrSquelchThresholdDb
: (autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25);
setSdrSquelch(threshold, true);
showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 1200);
}
sdrSquelchEl.value = String(nextPct);
updateSdrSquelchPctLabel();
saveSetting("sdrSquelchPct", nextPct);
submitSdrSquelchPercent(nextPct);
showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
} else {
showHint("Squelch N/A", 1200);
}
@@ -8505,8 +8794,13 @@ const bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
const bandplanRegionSelect = document.getElementById("bandplan-region-select") as HTMLSelectElement | null;
const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as HTMLInputElement | null;
(function loadBandplanJson() {
fetch("/bandplan.json")
// Fired at startup, and again once the session exists. The first attempt can
// land before the user is authenticated, and it used to fail silently and
// never retry, which is why the band plan sometimes only appeared after a
// manual reload. Redraws on arrival: the strip is painted from the spectrum
// draw, and a rig sitting between frames would otherwise stay blank.
function loadBandplanJson(): Promise<void> {
return fetch("/bandplan.json")
.then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
@@ -8516,9 +8810,13 @@ const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as
bandplanData = data as BandplanData;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
if (lastSpectrumData) scheduleSpectrumDraw();
})
.catch(() => {});
})();
.catch((err) => {
console.warn("Band plan unavailable", err);
});
}
void loadBandplanJson();
if (bandplanRegionSelect) {
bandplanRegionSelect.value = bandplanRegion;
@@ -8590,27 +8888,36 @@ function bandplanVisibleSegments(region: string, loHz: number, hiHz: number): Vi
return result;
}
function _hideBandplanStrip() {
// Empties the strip. `reserveSpace` keeps its height: the strip is in flow, so
// collapsing it on a range with no allocations shifted the whole page down by
// its height every time tuning crossed out of a band. Space stays reserved
// whenever a band plan could be drawn at all, and is only given back when the
// feature is off, has no data, or there is no spectrum to annotate.
function _clearBandplanStrip(reserveSpace: boolean) {
if (!bandplanStripEl) return;
bandplanStripEl.classList.remove("bp-visible");
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
if (bandplanCacheKey) {
bandplanStripEl.replaceChildren();
bandplanCacheKey = "";
}
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
}
function updateBandplanStrip(range: SpectrumRange | null) {
if (!bandplanStripEl) return;
if (!range || bandplanRegion === "off" || !bandplanData) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(false);
return;
}
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
if (segments.length === 0) {
if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
_clearBandplanStrip(true);
return;
}
bandplanStripEl.classList.add("bp-visible");
bandplanStripEl.classList.remove("bp-empty");
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" +
segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import type * as Leaflet from "leaflet";
import { aprsSymbolSprite } from "./plugins/aprs-shared";
export {};
@@ -1979,48 +1980,43 @@ const mapWindow = window as unknown as MapWindow;
if (aprsMap) aprsMap.invalidateSize();
return;
}
// Everything below is the windowed path — the fullscreen branch returned.
// The map tab is a whole page, so the stage fills the column down to the
// footer. Capping it at a fraction of the viewport, or at a width-derived
// aspect ratio, left a dead band under the map that grew with the window
// (and on narrow screens made the map barely a third of the page).
const mapRect = mapContainer.getBoundingClientRect();
const width = mapContainer.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = mapIsFullscreen() && stage
? stage.getBoundingClientRect().bottom
: window.innerHeight;
if (!mapIsFullscreen() && footer) {
let bottom = window.innerHeight;
if (footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = fr.top;
// Clamped to the viewport: once the column is tall enough to push the
// footer below the fold, growing into it would push it further still.
if (fr.top > mapRect.top + 50) bottom = Math.min(fr.top, bottom);
}
const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
const viewportCap = mapIsFullscreen()
? Math.floor(window.innerHeight * 0.9)
: Math.floor(window.innerHeight * 0.75);
const minHeight = Math.min(260, available);
const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
const target = Math.max(0, Math.floor(bottom - mapRect.top - 8));
mapContainer.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | null {
if (!symbolTable || !symbolCode) return null;
const table = symbolTable === "/" ? "primary" : "alternate";
const sprite = aprsSymbolSprite(symbolTable, symbolCode);
const html = sprite
? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>`
: `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
return L.divIcon({
className: "",
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
html,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
});
}
mapWindow.navigateToAprsMap = function(lat, lon) {
// Activate the map tab
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
function focusMapPosition(lat: number, lon: number) {
initAprsMap();
sizeAprsMapToViewport();
if (aprsMap) {
@@ -2032,20 +2028,12 @@ const mapWindow = window as unknown as MapWindow;
});
});
}
};
}
mapWindow.navigateToMapLocator = function(grid, preferredType = null) {
function focusMapLocator(grid: string, preferredType: string | null = null) {
const normalizedGrid = String(grid || "").trim().toUpperCase();
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
T._activeTab = "map";
document.querySelectorAll(".tab-bar .tab").forEach((t) => { t.classList.remove("active"); });
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
if (mapTabBtn) mapTabBtn.classList.add("active");
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((p) => (p.style.display = "none"));
const mapPanel = mapEl("tab-map");
if (mapPanel) mapPanel.style.display = "";
initAprsMap();
sizeAprsMapToViewport();
if (!aprsMap) return false;
@@ -2095,7 +2083,7 @@ const mapWindow = window as unknown as MapWindow;
requestAnimationFrame(focusMarker);
});
return true;
};
}
@@ -2806,7 +2794,7 @@ const mapWindow = window as unknown as MapWindow;
selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null;
syncDecodeContactPathVisibility();
if (selectedMapQsoKey && entry.sourceGrid) {
mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType);
focusMapLocator(entry.sourceGrid, entry.sourceType);
}
});
@@ -2933,7 +2921,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
@@ -3059,7 +3047,7 @@ const mapWindow = window as unknown as MapWindow;
card.className = "map-qso-card";
if (entry.grid) {
card.addEventListener("click", () => {
mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType);
focusMapLocator(entry.grid ?? "", entry.sourceType);
});
}
@@ -3623,6 +3611,8 @@ const mapWindow = window as unknown as MapWindow;
// Register module API for core to call
modules.map = {
initAprsMap,
focusMapPosition,
focusMapLocator,
sizeAprsMapToViewport,
syncAprsReceiverMarker,
updateMapRigFilter,
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {};
import type { PluginRuntimeWindow } from "./runtime-contract";
@@ -27,22 +29,15 @@ interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
interface AisBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
escapeMapHtml?: (input: string) => string;
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
serverLat?: number | null;
serverLon?: number | null;
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
aisMapAddVessel?: (message: AisMessage) => void;
clearMapMarkersByType?: (type: string) => void;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
updateAisBar?: () => void;
clearAisBar?: () => void;
}
const aisWindow = window as unknown as AisBridge;
const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const escapeAisHtml = (input: string): string => hostCore.escapeMapHtml(input);
// --- AIS Decoder Plugin (server-side decode) ---
const aisStatus = document.getElementById("ais-status");
@@ -176,10 +171,10 @@ function aisRouteText(msg: AisMessage): string {
}
function aisDistanceText(msg: AisMessage): string {
if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
return "";
}
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon);
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
@@ -405,7 +400,7 @@ function pruneAisHistoryView(): void {
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => {
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aisWindow.postPath?.("/clear_ais_decode");
await hostCore.postPath("/clear_ais_decode");
resetAisHistoryView();
} catch (e) {
console.error("AIS history clear failed", e);
@@ -108,8 +108,80 @@ function escapeAprsCharacter(character: string): string {
return character;
}
// The vendored sprite sheets (assets/web/vendor/aprs-symbols-24-*.png) are
// 16x6 grids of 24px cells covering the printable codes 0x21..0x7E, so a
// symbol's cell index is simply `code - 0x21`. The sheet URLs live in CSS so
// the retina variants can be picked up by a media query; only the cell offsets
// are computed here.
const APRS_SPRITE_COLUMNS = 16;
const APRS_SPRITE_CELL_PX = 24;
const APRS_SPRITE_FIRST_CODE = 0x21;
const APRS_SPRITE_LAST_CODE = 0x7e;
export interface AprsSymbolSprite {
/** Sheet modifier class appended to `.aprs-symbol`. */
className: string;
/** `background-position` covering the overlay layer first, if any. */
backgroundPosition: string;
/** Human-readable description for the tooltip. */
label: string;
}
function aprsSpriteOffset(code: string): string | null {
if (code.length !== 1) return null;
const point = code.charCodeAt(0);
if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null;
const index = point - APRS_SPRITE_FIRST_CODE;
const column = index % APRS_SPRITE_COLUMNS;
const row = Math.floor(index / APRS_SPRITE_COLUMNS);
return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`;
}
/**
* Resolve an APRS table/code pair to a sprite cell. A table identifier of `/`
* selects the primary sheet and `\` the alternate one; any other character is
* an overlay, which draws that character from the overlay sheet on top of the
* alternate symbol. Returns null when the pair is outside the sprite sheets,
* leaving callers to fall back to the raw character.
*/
export function aprsSymbolSprite(
symbolTable: string | null | undefined,
symbolCode: string | null | undefined,
): AprsSymbolSprite | null {
if (!symbolTable || !symbolCode) return null;
const symbolOffset = aprsSpriteOffset(symbolCode);
if (!symbolOffset) return null;
if (symbolTable === "/") {
return {
className: "aprs-symbol-primary",
backgroundPosition: symbolOffset,
label: `Primary APRS symbol ${symbolTable}${symbolCode}`,
};
}
if (symbolTable === "\\") {
return {
className: "aprs-symbol-alternate",
backgroundPosition: symbolOffset,
label: `Alternate APRS symbol ${symbolTable}${symbolCode}`,
};
}
const overlayOffset = aprsSpriteOffset(symbolTable);
if (!overlayOffset) return null;
return {
className: "aprs-symbol-overlaid",
backgroundPosition: `${overlayOffset}, ${symbolOffset}`,
label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}`,
};
}
export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string {
if (!packet.symbolTable || !packet.symbolCode) return "";
const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode);
if (sprite) {
return `<span class="aprs-symbol ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
@@ -18,26 +20,18 @@ import type { PluginRuntimeWindow } from "./runtime-contract";
interface AprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
serverLat?: number | null;
serverLon?: number | null;
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
escapeMapHtml?: (input: string) => string;
navigateToAprsMap?: (lat: number, lon: number) => void;
showHint?: (message: string, durationMs: number) => void;
clearMapMarkersByType?: (type: string) => void;
aprsMapAddStation?: (call: string, lat: number, lon: number, info: string, symbolTable: string | null | undefined, symbolCode: string | null | undefined, packet: AprsPacket) => void;
getDecodeRigMeta?: () => unknown;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
updateAprsBar?: () => void;
clearAprsBar?: () => void;
closeAprsBar?: () => void;
}
const aprsWindow = window as unknown as AprsBridge;
const escapeAprsHtml = (input: string): string => aprsWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const showAprsHint = (message: string, durationMs: number): void => { aprsWindow.showHint?.(message, durationMs); };
const escapeAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
const showAprsHint = (message: string, durationMs: number): void => { hostCore.showHint(message, durationMs); };
// --- APRS Decoder Plugin (server-side decode) ---
const aprsStatus = document.getElementById("aprs-status");
@@ -87,8 +81,8 @@ function scheduleAprsBarUpdate() {
}
function aprsDistanceText(pkt: AprsPacket): string {
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon);
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
@@ -354,7 +348,7 @@ function onServerAprsBatch(packets: AprsPacket[]): void {
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => { void (async () => {
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await aprsWindow.postPath?.("/clear_aprs_decode");
await hostCore.postPath("/clear_aprs_decode");
resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
export {};
interface DecoderDescriptor {
@@ -38,7 +40,6 @@ interface BackgroundDecodeStatus {
}
interface BackgroundBridge {
decoderRegistry?: DecoderDescriptor[];
authEnabled?: boolean;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
@@ -170,7 +171,7 @@ const bgdWindow = window as unknown as BackgroundBridge;
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || bgdWindow.authEnabled === false;
const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const panel = document.getElementById("background-decode-panel");
if (panel) {
panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {};
/* DOM IDs in the server-owned page are required by this feature; bmEl throws
@@ -19,14 +21,6 @@ interface Bookmark {
scope?: string;
}
interface DecoderDescriptor {
id: string;
label: string;
activation?: string;
active_modes?: string[];
bookmark_selectable?: boolean;
}
interface BookmarkService {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
@@ -44,31 +38,12 @@ interface VirtualChannelService {
}
interface BookmarkBridge {
authEnabled?: boolean;
authRole?: string | null;
lastActiveRigId?: string | null;
lastRigIds?: string[];
lastRigDisplayNames?: Record<string, string>;
lastFreqHz?: number;
lastModeName?: string;
lastSpectrumData?: unknown;
currentBandwidthHz?: number;
modeEl?: HTMLSelectElement | null;
decoderRegistry?: DecoderDescriptor[];
trx?: { modules?: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
trx: { modules: { bookmarks?: BookmarkService; vchan?: VirtualChannelService } };
trxUi: {
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
notify?(message: string, options: { kind: "error" }): void;
};
syncBookmarkMapLocators?(bookmarks: readonly Bookmark[]): void;
scheduleSpectrumDraw?(): void;
syncBandwidthInput?(bandwidthHz: number): void;
applyLocalTunedFrequency?(frequencyHz: number, force?: boolean): void;
setRigFrequency?(frequencyHz: number): Promise<unknown>;
postPath(path: string): Promise<unknown>;
onDecoderRegistryReady?(callback: () => void): void;
_freqOptimisticSeq?: number;
_freqOptimisticHz?: number;
}
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
@@ -79,6 +54,12 @@ function bmEl(id: string): BookmarkElement {
return element as BookmarkElement;
}
/* Decoder checkboxes and decoder toggle buttons are built from the runtime
* registry, so their elements are legitimately absent for unbuilt decoders. */
function bmOptionalEl(id: string): BookmarkElement | null {
return document.getElementById(id) as BookmarkElement | null;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -119,10 +100,7 @@ function bmEsc(str: unknown): string {
}
function bmCanControl() {
return (
(typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled) ||
(typeof bridge.authRole !== "undefined" && bridge.authRole === "control")
);
return !hostState.authEnabled || hostState.authRole === "control";
}
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
@@ -136,8 +114,7 @@ function bmSyncAccess() {
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() {
const rig = (typeof bridge.lastActiveRigId !== "undefined") ? bridge.lastActiveRigId : null;
return rig || "general";
return hostState.lastActiveRigId || "general";
}
async function bmFetchOverlay() {
@@ -154,7 +131,7 @@ async function bmFetchOverlay() {
if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList);
}
if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw();
hostCore.scheduleSpectrumDraw();
}
async function bmFetch(categoryFilter: string): Promise<void> {
@@ -310,19 +287,19 @@ function bmChangePage(delta: number): void {
// Read decoder checkboxes and return an array of selected decoder names.
function bmReadDecoders(): string[] {
return (bridge.decoderRegistry || [])
return hostState.decoderRegistry
.filter(d => d.bookmark_selectable)
.filter(d => bmEl("bm-dec-" + d.id)?.checked)
.filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
.map(d => d.id);
}
// Set decoder checkboxes to match the given array.
function bmWriteDecoders(decoders: readonly string[]): void {
const set = new Set(decoders || []);
(bridge.decoderRegistry || [])
hostState.decoderRegistry
.filter(d => d.bookmark_selectable)
.forEach(d => {
const el = bmEl("bm-dec-" + d.id);
const el = bmOptionalEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id);
});
}
@@ -332,7 +309,7 @@ function bmBuildDecoderCheckboxes() {
const container = bmEl("bm-decoder-checkboxes");
if (!container) return;
container.innerHTML = "";
(bridge.decoderRegistry || [])
hostState.decoderRegistry
.filter(d => d.bookmark_selectable)
.forEach(d => {
const label = document.createElement("label");
@@ -372,23 +349,21 @@ function bmCloseForm() {
}
function bmPrefillFromStatus() {
// Use globals maintained by app.js (updated by SSE stream)
if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) {
bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz));
// Read live rig state from the host contract (updated by the SSE stream).
const freqHz = hostState.lastFreqHz;
if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz));
}
if (typeof bridge.lastModeName === "string" && bridge.lastModeName) {
bmEl("bm-mode").value = bridge.lastModeName;
if (hostState.lastModeName) {
bmEl("bm-mode").value = hostState.lastModeName;
}
if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz));
if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
}
// Prefill decoder checkboxes from current toggle button state.
const activeDecoders = (bridge.decoderRegistry || [])
const activeDecoders = hostState.decoderRegistry
.filter(d => d.bookmark_selectable && d.activation === "toggle")
.filter(d => {
const btn = bmEl(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
})
.filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
.map(d => d.id);
bmWriteDecoders(activeDecoders);
}
@@ -478,58 +453,45 @@ async function bmDelete(id: string): Promise<void> {
function bmApply(bm: Bookmark): void {
try {
// --- Optimistic UI updates (instant, before any network round-trips) ---
if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) {
bridge.modeEl.value = (bm.mode || "").toUpperCase();
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
if (typeof bridge.currentBandwidthHz !== "undefined") {
bridge.currentBandwidthHz = bm.bandwidth_hz;
}
bridge.currentBandwidthHz = bm.bandwidth_hz;
if (typeof bridge.syncBandwidthInput === "function") {
bridge.syncBandwidthInput(bm.bandwidth_hz);
}
hostState.currentBandwidthHz = bm.bandwidth_hz;
hostCore.syncBandwidthInput(bm.bandwidth_hz);
}
if (typeof bridge.applyLocalTunedFrequency === "function") {
// Set optimistic guard before applying so SSE cannot snap back.
if (typeof bridge._freqOptimisticSeq !== "undefined") {
++bridge._freqOptimisticSeq;
bridge._freqOptimisticHz = bm.freq_hz;
}
// Force display so the BW overlay is repositioned even when freq is unchanged.
bridge.applyLocalTunedFrequency(bm.freq_hz, true);
}
if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) {
bridge.scheduleSpectrumDraw();
// Set optimistic guard before applying so SSE cannot snap back.
hostCore.armOptimisticFrequency(bm.freq_hz);
// Force display so the BW overlay is repositioned even when freq is unchanged.
hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
if (hostState.lastSpectrumData) {
hostCore.scheduleSpectrumDraw();
}
// Take scheduler control up front, then apply mode before bandwidth so a
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
const tunePromise = (async () => {
await bridge.trx?.modules?.vchan?.takeSchedulerControl();
await bridge.trx.modules.vchan?.takeSchedulerControl();
const onVirtual = await bridge.trx?.modules?.vchan?.interceptMode(bm.mode) ?? false;
const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
if (!onVirtual) {
await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
}
if (bm.bandwidth_hz) {
const bwHandledByVchan =
await bridge.trx?.modules?.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
if (!bwHandledByVchan) {
await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
}
}
// bridge.setRigFrequency is wrapped by vchan.js to redirect to the channel API
// when on a virtual channel, so this call works correctly in both cases.
// It also does its own optimistic update (bridge.applyLocalTunedFrequency) but
// that's a no-op since we already set the same value above.
if (typeof bridge.setRigFrequency === "function") {
await bridge.setRigFrequency(bm.freq_hz);
} else {
await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`);
}
// setRigFrequency redirects to the channel API when a virtual channel is
// active. It repeats the optimistic update applied above, which is a
// no-op because the value is unchanged.
hostCore.setRigFrequency(bm.freq_hz);
})();
// Decoder toggles — fire-and-forget.
// - Decoders incompatible with the new mode are always turned off
@@ -539,13 +501,14 @@ function bmApply(bm: Bookmark): void {
// alone.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (bridge.decoderRegistry || []).filter(d =>
const allToggleDecoders = hostState.decoderRegistry.filter(d =>
d.activation === "toggle"
);
const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status";
if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) {
statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId);
const rigId = hostState.lastActiveRigId;
if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId);
}
const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return;
@@ -567,7 +530,7 @@ function bmApply(bm: Bookmark): void {
wanted = currentlyOn;
}
if (wanted !== currentlyOn) {
toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
}
}
if (toggles.length) await Promise.all(toggles);
@@ -582,8 +545,6 @@ function bmApply(bm: Bookmark): void {
}
}
bridge.trx ??= {};
bridge.trx.modules ??= {};
bridge.trx.modules.bookmarks = {
get overlayList() { return bmOverlayList; },
get overlayRevision() { return bmOverlayRevision; },
@@ -619,8 +580,8 @@ function bmUpdateSelectionUi() {
function bmPopulateMoveTarget() {
const sel = bmEl("bm-move-target");
if (!sel) return;
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
const rigIds = hostState.lastRigIds;
const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value;
sel.innerHTML = "";
if (bmScope !== "general") {
@@ -728,8 +689,8 @@ async function bmDeleteSelected() {
function bmPopulateScopePicker() {
const picker = bmEl("bm-scope-picker");
if (!picker) return;
const rigIds = (typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds)) ? bridge.lastRigIds : [];
const displayNames = (typeof bridge.lastRigDisplayNames !== "undefined") ? bridge.lastRigDisplayNames : {};
const rigIds = hostState.lastRigIds;
const displayNames = hostState.lastRigDisplayNames;
// Preserve current selection if still valid.
const prev = picker.value;
while (picker.options.length > 1) picker.remove(1);
@@ -756,9 +717,7 @@ function bmPopulateScopePicker() {
// Build decoder checkboxes from registry. The registry is fetched async
// so we rebuild once it arrives to ensure checkboxes are present.
bmBuildDecoderCheckboxes();
if (typeof bridge.onDecoderRegistryReady === "function") {
bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
// Scope picker
bmPopulateScopePicker();
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -30,8 +32,6 @@ interface CwBridge {
lastFreqHz?: number;
currentBandwidthHz?: number;
lastSpectrumData?: CwSpectrum;
escapeMapHtml?: (input: string) => string;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
applyCwAutoUi?: (enabled: boolean) => void;
applyCwAutoUiFromServer?: (enabled: boolean) => void;
@@ -75,9 +75,7 @@ let cwBarDismissedAtMs = 0;
let cwAutoLocalOverride: boolean | null = null;
function escapeCwHtml(input: string): string {
return cwWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
return hostCore.escapeMapHtml(input);
}
function applyCwAutoUi(enabled: boolean): void {
@@ -344,7 +342,7 @@ async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: bool
cwToneInput.value = String(clamped);
}
try {
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
@@ -358,7 +356,7 @@ if (cwAutoInput) {
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (error: unknown) {
console.error("CW auto toggle failed", error);
@@ -375,7 +373,7 @@ if (cwWpmInput) {
if (cwAutoInput?.checked) return;
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = String(wpm);
try { await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
try { await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (error: unknown) { console.error("CW WPM set failed", error); }
})();
});
@@ -413,7 +411,7 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
void (async () => {
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await cwWindow.postPath?.("/clear_cw_decode");
await hostCore.postPath("/clear_cw_decode");
resetCwHistoryView();
} catch (error: unknown) {
console.error("CW history clear failed", error);
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
@@ -45,8 +47,6 @@ interface FtxBridge {
updateFt8Bar?: () => void;
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
postPath?: (path: string) => Promise<unknown>;
fmtTime?: (timestampMs: number) => string;
trxUi: ConfirmApi;
clearFt8Bar?: () => void;
closeFt8Bar?: () => void;
@@ -62,6 +62,14 @@ interface FtxConfig {
const bridge = window as unknown as FtxBridge & PluginRuntimeWindow;
// Legacy ft8.js owned this formatter locally; the shared module kept reading it
// off `window`, where nothing published it, so bar timestamps rendered empty.
function formatBarTime(timestampMs: number): string {
if (!timestampMs) return "--:--:--";
return new Date(timestampMs)
.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function finiteNumber(value: unknown): number | null {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null;
@@ -274,7 +282,7 @@ export function initializeFtxDecoder(config: FtxConfig): void {
let html = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${formatBarTime(timestamp)}</span>`;
const snr = finiteNumber(message.snr_db);
const delta = finiteNumber(message.dt_s);
const frequency = displayFrequency(message.freq_hz);
@@ -309,12 +317,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
toggle?.addEventListener("click", () => { void (async () => {
try {
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
await bridge.postPath?.(`/toggle_${id}_decode`);
await hostCore.postPath(`/toggle_${id}_decode`);
} catch (error: unknown) { console.error(`${label} toggle failed`, error); }
})(); });
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => { void (async () => {
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
try { await bridge.postPath?.(`/clear_${id}_decode`); reset(); }
try { await hostCore.postPath(`/clear_${id}_decode`); reset(); }
catch (error: unknown) { console.error(`${label} history clear failed`, error); }
})(); });
}
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsCategoryLabel,
@@ -19,21 +21,13 @@ import type { PluginRuntimeWindow } from "./runtime-contract";
interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
serverLat?: number | null;
serverLon?: number | null;
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
escapeMapHtml?: (input: string) => string;
navigateToAprsMap?: (lat: number, lon: number) => void;
showHint?: (message: string, durationMs: number) => void;
getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
}
const hfAprsWindow = window as unknown as HfAprsBridge;
const escapeHfAprsHtml = (input: string): string => hfAprsWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input);
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
@@ -72,8 +66,8 @@ function scheduleHfAprsHistoryRender() {
}
function hfAprsDistanceText(pkt: AprsPacket): string {
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return "";
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon);
if (hostState.serverLat == null || hostState.serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, pkt.lat, pkt.lon);
if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`;
@@ -214,10 +208,10 @@ function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (clipboard) {
await clipboard.writeText(raw);
hfAprsWindow.showHint?.("Coordinates copied", 1200);
hostCore.showHint("Coordinates copied", 1200);
}
} catch {
hfAprsWindow.showHint?.("Copy failed", 1500);
hostCore.showHint("Copy failed", 1500);
}
})(); });
}
@@ -289,7 +283,7 @@ const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn
hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
try {
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode");
await hostCore.postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
@@ -298,7 +292,7 @@ hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => { void (async () => {
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
await hostCore.postPath("/clear_hf_aprs_decode");
resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
@@ -0,0 +1,74 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Typed view of the `window.trx` host contract for lazy feature bundles.
//
// Feature entries are separate esbuild bundles, so they cannot share module
// instances with the application entry and must reach application state and
// services through the host namespace documented in
// `docs/frontend-architecture.md`. Declaring that contract once here keeps the
// feature bundles from re-deriving it — and from drifting back to bare `window`
// properties, which the module graph no longer publishes.
export interface HostDecoderDescriptor {
id: string;
label: string;
activation?: string;
active_modes?: string[];
bookmark_selectable?: boolean;
}
export interface HostState {
readonly serverLat: number | null;
readonly serverLon: number | null;
readonly authEnabled: boolean;
readonly authRole: string | null;
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
readonly lastFreqHz: number | null;
readonly lastSpectrumData: { sample_rate: number; center_hz: number } | null;
readonly jogUnit: number;
readonly rxActive: boolean;
readonly decoderRegistry: readonly HostDecoderDescriptor[];
lastModeName: string;
currentBandwidthHz: number;
audioChannelOverride: string | null;
}
export interface HostCore {
postPath(path: string): Promise<unknown>;
applyLocalTunedFrequency(frequencyHz: number, forceDisplay?: boolean): void;
armOptimisticFrequency(frequencyHz: number): void;
escapeMapHtml(value: string): string;
haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number;
showHint(message: string, durationMs?: number): void;
formatFreqForStep(frequencyHz: number, stepHz: number): string;
refreshFreqDisplay(): void;
setJogDivisor(divisor: number): void;
mwDefaultsForMode(mode: string): [number, number, number, number];
resetRdsDisplay(): void;
positionRdsPsOverlay(): void;
updateWfmControls(): void;
updateSdrSquelchControlVisibility(): void;
updateDocumentTitle(rds: unknown): void;
activeChannelRds(): unknown;
startRxAudio(): void;
stopRxAudio(): void;
setRigFrequency(frequencyHz: number): void;
syncBandwidthInput(bandwidthHz: number): void;
scheduleSpectrumDraw(): void;
/** Repaints the mode buttons from #mode after writing to it. */
syncModePicker(): void;
onDecoderRegistryReady(callback: () => void): void;
}
interface HostWindow {
trx: { state: HostState; core: HostCore };
}
const host = window as unknown as HostWindow;
export const hostState: HostState = host.trx.state;
export const hostCore: HostCore = host.trx.core;
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
@@ -13,7 +15,6 @@ interface SatelliteBridge {
addSatMapOverlay?: (image: SatelliteImage) => void;
clearSatMapOverlays?: () => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
satShowOnMap?: (south: number, west: number, north: number, east: number) => void;
enableMapSourceFilter?: (source: string) => void;
@@ -305,7 +306,7 @@ const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
try {
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await satWindow.postPath?.("/toggle_lrpt_decode");
await hostCore.postPath("/toggle_lrpt_decode");
} catch (e) {
console.error("LRPT toggle failed", e);
}
@@ -327,7 +328,7 @@ document
?.addEventListener("click", () => { void (async () => {
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await satWindow.postPath?.("/clear_lrpt_decode");
await hostCore.postPath("/clear_lrpt_decode");
resetSatHistoryView();
} catch (e) {
console.error("Weather satellite history clear failed", e);
@@ -80,9 +80,5 @@ export interface SchedulerWindow {
confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise<boolean>;
notify?(message: string, options: { kind: "error" }): void;
};
authRole?: string | null;
lastActiveRigId?: string | null;
serverLat?: number | null;
serverLon?: number | null;
satScheduler?: SatelliteSchedulerApi;
}
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
import type {
ScheduleEntry,
SchedulerBookmark,
@@ -27,6 +29,12 @@ function schedulerEl(id: string): SchedulerElement {
return element as SchedulerElement;
}
/* The timeline needle group is drawn by this feature's own SVG markup, so it is
* absent until the timeline has rendered at least once. */
function schedulerOptionalEl(id: string): SchedulerElement | null {
return document.getElementById(id) as SchedulerElement | null;
}
// Background Decoding Scheduler UI
(function () {
@@ -481,8 +489,8 @@ function schedulerEl(id: string): SchedulerElement {
if (mode === "grayline" && currentConfig && currentConfig.grayline) {
const gl = currentConfig.grayline;
// Prefer saved value; fall back to server coordinates from app.js globals.
const lat = gl.lat ?? schedulerWindow.serverLat ?? "";
const lon = gl.lon ?? schedulerWindow.serverLon ?? "";
const lat = gl.lat ?? hostState.serverLat ?? "";
const lon = gl.lon ?? hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl = schedulerEl("scheduler-gl-grid");
@@ -496,8 +504,8 @@ function schedulerEl(id: string): SchedulerElement {
renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
} else if (mode === "grayline") {
// No saved grayline config yet — pre-fill coords from server if available.
const lat = schedulerWindow.serverLat ?? "";
const lon = schedulerWindow.serverLon ?? "";
const lat = hostState.serverLat ?? "";
const lon = hostState.serverLon ?? "";
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
const gridEl2 = schedulerEl("scheduler-gl-grid");
@@ -821,7 +829,7 @@ function schedulerEl(id: string): SchedulerElement {
}
function renderTimelineNeedle() {
const g = schedulerEl("sch-timeline-needle-g");
const g = schedulerOptionalEl("sch-timeline-needle-g");
if (g) g.innerHTML = timelineNeedleSvg();
}
@@ -1566,8 +1574,8 @@ function schedulerEl(id: string): SchedulerElement {
// When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
// when loaded lazily (e.g. settings tab click after boot), the app has
// already passed that point, so we must self-initialize here.
if (schedulerWindow.authRole != null) {
initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole);
if (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
wireSchedulerEvents();
}
})();
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {};
interface VirtualChannel {
@@ -21,35 +23,12 @@ interface SchedulerReleaseState {
all_released?: boolean;
current_session_released?: boolean;
}
interface SpectrumSnapshot { sample_rate: number; center_hz: number }
// Callbacks the application publishes as documented transitional window
// properties; all other application state and services arrive through the
// typed host contract in ./host.ts.
interface VirtualChannelBridge {
lastActiveRigId?: string | null;
lastFreqHz?: number;
lastModeName?: string;
lastSpectrumData?: SpectrumSnapshot | null;
currentBandwidthHz?: number;
jogUnit?: number;
rxActive?: boolean;
_audioChannelOverride?: string | null;
_freqOptimisticSeq?: number;
_freqOptimisticHz?: number;
renderRdsOverlays?: () => void;
updateDocumentTitle?: (rds: unknown) => void;
activeChannelRds?: () => unknown;
stopRxAudio?: () => void;
startRxAudio?: () => void;
formatFreqForStep?: (frequencyHz: number, jogUnit: number) => string;
setJogDivisor?: (divisor: number) => void;
resetRdsDisplay?: () => void;
updateWfmControls?: () => void;
updateSdrSquelchControlVisibility?: () => void;
refreshRdsUi?: () => void;
positionRdsPsOverlay?: () => void;
mwDefaultsForMode?: (mode: string) => [number, ...unknown[]];
showHint?: (message: string, durationMs: number) => void;
applyLocalTunedFrequency?: (frequencyHz: number) => void;
setRigFrequency?: (frequencyHz: number) => void;
refreshFreqDisplay?: () => void;
trx?: { modules?: { vchan?: VirtualChannelService } };
}
@@ -63,6 +42,8 @@ interface VirtualChannelService {
isOnVirtual(): boolean;
interceptMode(mode: string): Promise<boolean>;
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
interceptFrequency(frequencyHz: number): boolean;
interceptFreqDisplay(): boolean;
takeSchedulerControl(): Promise<void>;
releaseToScheduler(): Promise<void>;
}
@@ -146,7 +127,7 @@ function vchanStartSchedulerReleasePolling() {
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
const rigId = vchanRigId || hostState.lastActiveRigId || null;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
@@ -261,9 +242,7 @@ function vchanRender() {
picker.appendChild(addBtn);
vchanSyncAccentUI();
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
}
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanRenderSchedulerRelease();
}
@@ -271,8 +250,8 @@ async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
// Use the last known rig frequency and mode as the starting point.
const freqHz = (typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0)
? vchanWindow.lastFreqHz
const freqHz = (typeof hostState.lastFreqHz === "number" && hostState.lastFreqHz > 0)
? hostState.lastFreqHz
: 0;
const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
const mode = modeEl ? (modeEl.value || "USB") : "USB";
@@ -374,14 +353,14 @@ function vchanReconnectAudio(): void {
// Always update the override so startRxAudio picks up the right URL,
// even when audio isn't currently running.
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
vchanWindow._audioChannelOverride = ch?.id ?? null;
if (!vchanWindow.rxActive) return;
vchanWindow.stopRxAudio?.();
hostState.audioChannelOverride = ch?.id ?? null;
if (!hostState.rxActive) return;
hostCore.stopRxAudio();
// Delay so the server has time to set up the per-channel encoder.
// The server-side audio_ws handler also polls for up to 2 s, so this
// just needs to be long enough for the WS upgrade to reach the server.
setTimeout(() => {
vchanWindow.startRxAudio?.();
hostCore.startRxAudio();
}, 300);
}
@@ -414,11 +393,7 @@ function vchanUpdateFreqDisplay() {
if (!ch) return;
const el = document.getElementById("freq") as HTMLInputElement | null;
if (!el) return;
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
} else {
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
}
el.value = hostCore.formatFreqForStep(ch.freq_hz, hostState.jogUnit);
}
// Sync the mode picker to the active virtual channel's mode.
@@ -428,25 +403,28 @@ function vchanSyncModeDisplay() {
if (!modeEl) return;
if (vchanIsOnVirtual()) {
const ch = vchanActiveChannel();
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
if (ch && ch.mode) {
modeEl.value = ch.mode.toUpperCase();
hostCore.syncModePicker();
}
}
// When on primary channel, app.js rig-state updates handle the picker.
const modeUpper = (modeEl.value || "").toUpperCase();
if (typeof vchanWindow.lastModeName === "string") {
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
vchanWindow.setJogDivisor?.(10);
vchanWindow.resetRdsDisplay?.();
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
vchanWindow.resetRdsDisplay?.();
if (typeof hostState.lastModeName === "string") {
if (modeUpper === "WFM" && hostState.lastModeName !== "WFM") {
hostCore.setJogDivisor(10);
hostCore.resetRdsDisplay();
} else if (modeUpper !== "WFM" && hostState.lastModeName === "WFM") {
hostCore.resetRdsDisplay();
}
vchanWindow.lastModeName = modeUpper;
hostState.lastModeName = modeUpper;
}
vchanWindow.updateWfmControls?.();
vchanWindow.updateSdrSquelchControlVisibility?.();
hostCore.updateWfmControls();
hostCore.updateSdrSquelchControlVisibility();
if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi();
} else {
vchanWindow.positionRdsPsOverlay?.();
hostCore.positionRdsPsOverlay();
}
}
@@ -459,12 +437,12 @@ function vchanSyncBwDisplay() {
if (!bwEl) return;
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
if (bwHz === 0) {
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
vchanWindow.currentBandwidthHz = bwHz;
hostState.currentBandwidthHz = bwHz;
}
}
@@ -480,29 +458,22 @@ function vchanSyncAccentUI() {
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else {
origRefreshFreqDisplay?.();
}
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
hostCore.refreshFreqDisplay();
}
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
}
// Saved reference to the original refreshFreqDisplay from app.js.
let origRefreshFreqDisplay: (() => void) | null = null;
function vchanSetChannelFreq(freqHz: number): void {
if (!vchanRigId || !vchanActiveId) return;
// Validate against current SDR capture window.
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
const center = vchanWindow.lastSpectrumData.center_hz;
if (hostState.lastSpectrumData && hostState.lastSpectrumData.sample_rate > 0) {
const halfSpan = hostState.lastSpectrumData.sample_rate / 2;
const center = hostState.lastSpectrumData.center_hz;
if (Math.abs(freqHz - center) > halfSpan) {
if (vchanWindow.showHint) {
vchanWindow.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3000
);
}
hostCore.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3000
);
return;
}
}
@@ -571,6 +542,26 @@ async function vchanInterceptBandwidth(bwHz: number): Promise<boolean> {
return true;
}
// Called by setRigFrequency before it posts /set_freq. When a non-primary
// channel is active the change belongs to the channel API instead of the rig,
// so this applies the optimistic local update and reports the tune as handled.
function vchanInterceptFrequency(freqHz: number): boolean {
if (!vchanIsOnVirtual()) return false;
const targetHz = Math.round(freqHz);
hostCore.armOptimisticFrequency(targetHz);
hostCore.applyLocalTunedFrequency(targetHz);
vchanSetChannelFreq(freqHz);
return true;
}
// Called by the application's refreshFreqDisplay. While a virtual channel is
// active the field belongs to that channel, so report the render as handled.
function vchanInterceptFreqDisplay(): boolean {
if (!vchanIsOnVirtual()) return false;
vchanUpdateFreqDisplay();
return true;
}
vchanWindow.trx ??= {};
vchanWindow.trx.modules ??= {};
vchanWindow.trx.modules.vchan = {
@@ -583,34 +574,12 @@ vchanWindow.trx.modules.vchan = {
isOnVirtual: vchanIsOnVirtual,
interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease,
};
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
// frequency changes are redirected to the active virtual channel instead of
// the server when on a non-primary channel.
(function() {
const original = vchanWindow.setRigFrequency;
vchanWindow.setRigFrequency = function(freqHz: number) {
if (vchanIsOnVirtual()) {
// Optimistic local update first, then fire-and-forget channel API.
if (vchanWindow.applyLocalTunedFrequency) {
if (typeof vchanWindow._freqOptimisticSeq === "number") {
vchanWindow._freqOptimisticSeq += 1;
vchanWindow._freqOptimisticHz = Math.round(freqHz);
}
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
}
vchanSetChannelFreq(freqHz);
return;
}
// Scheduler control is fire-and-forget — don't block the freq change.
void vchanTakeSchedulerControl();
original?.(freqHz);
};
})();
(function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn");
if (btn) {
@@ -622,16 +591,3 @@ vchanWindow.trx.modules.vchan = {
vchanRenderSchedulerRelease();
})();
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
// active virtual channel's frequency (SSE rig-state updates would otherwise
// constantly overwrite it with channel 0's freq).
(function() {
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
vchanWindow.refreshFreqDisplay = function() {
if (vchanIsOnVirtual()) {
vchanUpdateFreqDisplay();
return;
}
origRefreshFreqDisplay?.();
};
})();
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
export {};
import type { PluginRuntimeWindow } from "./runtime-contract";
@@ -38,8 +40,6 @@ interface VdesMessage {
interface VdesBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
escapeMapHtml?: (input: string) => string;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
vdesMapAddPoint?: (message: VdesMessage) => void;
updateVdesBar?: () => void;
@@ -47,9 +47,7 @@ interface VdesBridge {
}
const vdesWindow = window as unknown as VdesBridge;
const escapeVdesHtml = (input: string): string => vdesWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
const escapeVdesHtml = (input: string): string => hostCore.escapeMapHtml(input);
// --- VDES Decoder Plugin (server-side decode) ---
const vdesStatus = document.getElementById("vdes-status");
@@ -337,7 +335,7 @@ function onServerVdesBatch(messages: VdesMessage[]): void {
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => { void (async () => {
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await vdesWindow.postPath?.("/clear_vdes_decode");
await hostCore.postPath("/clear_vdes_decode");
resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -29,7 +31,6 @@ interface WefaxProgress {
interface WefaxBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
postPath?: (path: string) => Promise<unknown>;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
syncWefaxToggle?: (enabled: boolean) => void;
}
@@ -411,7 +412,7 @@ if (wefaxDom.toggleBtn) {
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
}
await wefaxWindow.postPath?.('/toggle_wefax_decode');
await hostCore.postPath('/toggle_wefax_decode');
} catch (e) {
console.error('WEFAX toggle failed', e);
}
@@ -420,7 +421,7 @@ if (wefaxDom.toggleBtn) {
if (wefaxDom.clearBtn) {
wefaxDom.clearBtn.addEventListener('click', () => { void (async () => {
try {
await wefaxWindow.postPath?.('/clear_wefax_decode');
await hostCore.postPath('/clear_wefax_decode');
resetWefaxHistoryView();
} catch (e) {
console.error('WEFAX clear failed', e);
@@ -2,6 +2,8 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
@@ -25,7 +27,6 @@ interface WsprBridge {
navigateToMapLocator?: (grid: string, type: "wspr") => void;
clearMapMarkersByType?: (type: "wspr") => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
[key: string]: unknown;
}
@@ -291,7 +292,7 @@ wsprDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
try {
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await wsprWindow.postPath?.("/toggle_wspr_decode");
await hostCore.postPath("/toggle_wspr_decode");
} catch (error: unknown) {
console.error("WSPR toggle failed", error);
}
@@ -302,7 +303,7 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
void (async () => {
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await wsprWindow.postPath?.("/clear_wspr_decode");
await hostCore.postPath("/clear_wspr_decode");
resetWsprHistoryView();
} catch (error: unknown) {
console.error("WSPR history clear failed", error);
@@ -195,6 +195,31 @@ function elementById<T extends HTMLElement>(id: string): T {
const layoutCapabilities: Record<LayoutCapability, boolean> = { broadcast: false, digital: false };
let activeRigId: string | null = null;
// A layout seeds the collapsible sections; it does not hold them there.
// applyLayout runs on every rig-state refresh, so re-applying the disclosure
// state unconditionally would reopen or close sections under the operator
// once a second — the reason a section could not be expanded while a layout
// that collapses it was selected. Sections are therefore written only when
// the layout actually changes, or the first time each one appears in the DOM
// (the advanced controls are built after the first applyLayout call).
const layoutSections: { id: string; key: "advanced" | "audio" | "scheduler" }[] = [
{ id: "advanced-radio-controls", key: "advanced" },
{ id: "audio-controls", key: "audio" },
{ id: "scheduler-controls", key: "scheduler" },
];
const seededSections = new Set<string>();
let appliedLayoutName: LayoutName | null = null;
function seedLayoutSections(layout: OperatorLayout, layoutChanged: boolean) {
layoutSections.forEach(({ id, key }) => {
const section = document.getElementById(id) as HTMLDetailsElement | null;
if (!section) return;
if (!layoutChanged && seededSections.has(id)) return;
seededSections.add(id);
section.open = layout[key];
});
}
function layoutStorageKey() {
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
}
@@ -255,12 +280,9 @@ function elementById<T extends HTMLElement>(id: string): T {
const layout = layouts[permittedName];
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName);
const details = document.getElementById("advanced-radio-controls") as HTMLDetailsElement | null;
if (details) details.open = layout.advanced;
const audioDetails = document.getElementById("audio-controls") as HTMLDetailsElement | null;
if (audioDetails) audioDetails.open = layout.audio;
const schedulerDetails = document.getElementById("scheduler-controls") as HTMLDetailsElement | null;
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
const layoutChanged = appliedLayoutName !== permittedName;
appliedLayoutName = permittedName;
seedLayoutSections(layout, layoutChanged);
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
browserWindow.navigateToTab(layout.preferredTab);
}
@@ -295,11 +317,163 @@ function elementById<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (element) body.appendChild(element);
});
tray.appendChild(details);
// Ahead of the collapsibles the markup ships: the radio's own settings
// come before audio and the scheduler, and appending would put the
// section built here last whatever the markup says.
tray.insertBefore(details, document.getElementById("audio-controls"));
api.applyLayout(savedLayoutName(), { persist: false });
}
}
// Secondary controls, in the order they leave the bar when it gets tight.
// Audio, record and the rig picker are the operating controls and stay.
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
// Anchored at paint time in fixed coordinates. An absolutely positioned
// dropdown is clipped by any scrolling ancestor and trapped inside the
// stacking context its ancestors create, so it can end up squashed inside
// the bar or painted underneath page content. Fixed positioning answers to
// the viewport instead, and the menu flips above its button near the bottom
// edge.
function anchorMenu(button: HTMLElement, menu: HTMLElement) {
const rect = button.getBoundingClientRect();
// Reparent to the body. Fixed positioning escapes clipping but not
// stacking: the header sets `z-index: 2`, which makes it a stacking
// context, so any z-index the menu carries is confined below every panel
// that paints above level 2 — the spectrum overlays reach 9600. Only
// leaving that subtree lets the menu sit above the page.
if (menu.parentElement !== document.body) document.body.appendChild(menu);
menu.style.position = "fixed";
menu.style.zIndex = "9700";
menu.style.top = "0px";
menu.style.bottom = "auto";
// The stylesheet pins these menus to the right of their anchor; leaving
// that in place while setting a left would stretch them across the bar.
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() {
const actions = document.querySelector<HTMLElement>(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
const wrap = document.createElement("div");
wrap.id = "top-bar-more";
wrap.className = "top-bar-more";
const button = document.createElement("button");
button.type = "button";
button.id = "top-bar-more-btn";
button.className = "header-bar-btn top-bar-more-btn";
button.textContent = "⋯";
button.setAttribute("aria-haspopup", "menu");
button.setAttribute("aria-expanded", "false");
button.setAttribute("aria-label", "More controls");
button.title = "More controls";
const menu = document.createElement("div");
menu.id = "top-bar-more-menu";
menu.className = "top-bar-more-menu";
menu.setAttribute("role", "menu");
button.setAttribute("aria-controls", menu.id);
wrap.append(button, menu);
actions.appendChild(wrap);
const closeMenu = () => {
menu.classList.remove("is-open");
button.setAttribute("aria-expanded", "false");
};
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node)) { closeMenu(); return; }
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
// What "fits" means, measured rather than predicted. Two things have to
// hold: the controls stay inside the bar, and no tab reaches them. The
// tabs are the test rather than the nav's own box because the nav may
// shrink below its content — its box gets smaller while the tabs inside
// keep their width and slide under the controls, so the container reports
// nothing wrong while destinations become unclickable.
//
// This was an arithmetic estimate — identity + nav.scrollWidth +
// actions.scrollWidth + a 48px allowance for the gaps — which under-counts
// whatever the allowance does not cover. On a platform whose fonts run
// wider than this machine's it declared a fit that overlapped by 9px, and
// nothing degraded because nothing thought anything was wrong. Reading
// the geometry costs a synchronous layout per step, at most five per pass,
// and cannot disagree with what the operator sees.
const barFits = () => {
const bar = actions.closest<HTMLElement>(".tab-bar");
const nav = bar?.querySelector<HTMLElement>(".tab-bar-nav");
if (!bar || !nav) return true;
const barRect = bar.getBoundingClientRect();
const actionsRect = actions.getBoundingClientRect();
if (actionsRect.right > barRect.right + 1) return false;
const tabs = Array.from(nav.querySelectorAll<HTMLElement>(".tab"))
.filter((tab) => tab.offsetParent !== null);
if (!tabs.length) return true;
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
return tabsRight <= actionsRect.left - 1;
};
const reflowOverflow = () => {
const nav = document.querySelector<HTMLElement>(".tab-bar-nav");
const bar = actions.closest<HTMLElement>(".tab-bar");
// Measure from the roomiest state every time, so the decision is a
// function of the current widths alone and cannot ratchet.
nav?.classList.remove("nav-icons-only");
bar?.classList.remove("bar-tight");
overflowOrder.forEach((selector) => {
const element = menu.querySelector<HTMLElement>(selector);
if (element) actions.insertBefore(element, wrap);
});
wrap.hidden = true;
for (const selector of overflowOrder) {
if (barFits()) break;
const element = actions.querySelector<HTMLElement>(selector);
if (!element) continue;
wrap.hidden = false;
menu.appendChild(element);
}
// Last resort, once every movable control is already in the menu: drop
// the tabs to their icons. Without it the nav — which may shrink below
// its content — keeps its tabs at full width and runs them under the
// controls, so the destinations nearest the controls become unclickable.
// Icon widths are fixed, so this always buys back the labels' width.
if (nav && !barFits()) nav.classList.add("nav-icons-only");
// Still short with the tabs down to icons: hand the squeeze to the
// identity block, which can ellipsise, rather than to the strip, which
// can only clip destinations out of reach.
if (bar && !barFits()) bar.classList.add("bar-tight");
wrap.hidden = menu.children.length === 0;
if (wrap.hidden) closeMenu();
};
reflowOverflow();
window.addEventListener("resize", reflowOverflow);
// A resize is not the only thing that changes what fits: the rig name
// arrives from the server, the style picker fills in, a web font swaps in
// wider metrics. Each changes the bar's content without touching the
// window, and the strip stayed as it was through all of them.
if (typeof ResizeObserver !== "undefined") {
const bar = actions.closest<HTMLElement>(".tab-bar");
const observer = new ResizeObserver(() => { reflowOverflow(); });
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => { reflowOverflow(); }).catch(() => {});
}
function installMobileMore() {
const nav = document.querySelector(".tab-bar-nav");
if (!nav || document.getElementById("mobile-more-btn")) return;
@@ -307,7 +481,8 @@ function elementById<T extends HTMLElement>(id: string): T {
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
more.setAttribute("aria-label", "Tools and settings");
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
@@ -339,7 +514,10 @@ function elementById<T extends HTMLElement>(id: string): T {
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
if (open) {
anchorMenu(more, menu);
menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore();
@@ -404,6 +582,7 @@ function elementById<T extends HTMLElement>(id: string): T {
api.init = function init() {
ensureLiveRegions();
installLayoutControls();
installTopBarOverflow();
installMobileMore();
installDecoderPicker();
installDecoderBadges();
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("AIS entry forwards positioned vessels with normalized metadata", async () => {
const forwarded = [];
const window = {
...createHost(),
trxUi: { confirm: async () => true },
aisMapAddVessel: (message) => { forwarded.push(message); },
};
@@ -25,7 +26,7 @@ test("AIS entry forwards positioned vessels with normalized metadata", async ()
console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/ais.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { readFile } from "node:fs/promises";
import { bundleEntry } from "./bundle-entry.mjs";
const sharedUrl = new URL("../src/plugins/aprs-shared.ts", import.meta.url);
const stylePath = new URL("../../assets/web/style.css", import.meta.url);
async function loadShared() {
const source = await bundleEntry(sharedUrl, "trxAprsShared");
const context = vm.createContext({ Math, String, Number, Array, Date, Set, console });
new vm.Script(source).runInContext(context);
return context.trxAprsShared;
}
// The sheets are 16x6 grids of 24px cells covering 0x21..0x7E, so cell index
// is `code - 0x21`. Getting this wrong shifts every station to a neighbouring
// icon, which is invisible in a screenshot but wrong on every packet.
test("sprite cells are indexed from the first printable symbol code", async () => {
const { aprsSymbolSprite } = await loadShared();
const first = aprsSymbolSprite("/", "!");
assert.equal(first.className, "aprs-symbol-primary");
assert.equal(first.backgroundPosition, "0px 0px");
assert.equal(first.label, "Primary APRS symbol /!");
// '>' is 0x3E -> index 29 -> column 13, row 1.
assert.equal(aprsSymbolSprite("/", ">").backgroundPosition, "-312px -24px");
// '~' is 0x7E -> index 93 -> the last cell of the last row.
assert.equal(aprsSymbolSprite("/", "~").backgroundPosition, "-312px -120px");
});
test("the table identifier selects the primary, alternate, or overlay sheet", async () => {
const { aprsSymbolSprite } = await loadShared();
assert.equal(aprsSymbolSprite("/", "_").className, "aprs-symbol-primary");
assert.equal(aprsSymbolSprite("\\", "_").className, "aprs-symbol-alternate");
// An alphanumeric table identifier is an overlay character drawn on top of
// the alternate symbol: overlay cell first, then the symbol cell.
const overlaid = aprsSymbolSprite("S", ">");
assert.equal(overlaid.className, "aprs-symbol-overlaid");
assert.equal(overlaid.backgroundPosition, "-48px -72px, -312px -24px");
assert.equal(overlaid.label, "Alternate APRS symbol \\> with overlay S");
});
test("codes outside the sprite sheets fall back to the raw character", async () => {
const { aprsSymbolSprite, renderLocalAprsSymbol } = await loadShared();
const escape = (value) => value;
assert.equal(aprsSymbolSprite("/", " "), null);
assert.equal(aprsSymbolSprite("/", ""), null);
assert.equal(aprsSymbolSprite("/", "ab"), null);
assert.equal(aprsSymbolSprite(null, ">"), null);
assert.equal(renderLocalAprsSymbol({ symbolTable: "/", symbolCode: " " }, escape),
'<span class="aprs-symbol aprs-symbol-local" title="Primary APRS symbol "> </span>');
assert.equal(renderLocalAprsSymbol({}, escape), "");
});
test("rendered symbols carry a sprite class and an inline cell offset", async () => {
const { renderLocalAprsSymbol } = await loadShared();
const html = renderLocalAprsSymbol({ symbolTable: "/", symbolCode: ">" }, (value) => value);
assert.match(html, /class="aprs-symbol aprs-symbol-primary"/);
assert.match(html, /style="background-position:-312px -24px"/);
assert.match(html, /aria-label="Primary APRS symbol \/>"/);
assert.equal(html.includes("http"), false);
});
// The cell offsets are computed in the bundles, so the sheet URLs and the grid
// geometry have to stay in lockstep with them here.
test("the stylesheet serves every sheet locally at the sprite geometry", async () => {
const css = await readFile(stylePath, "utf8");
assert.match(css, /\.aprs-symbol\s*\{[^}]*background-size:\s*384px 144px/);
for (const sheet of ["24-0", "24-1", "24-2", "24-0-2x", "24-1-2x", "24-2-2x"]) {
assert.ok(css.includes(`url('/vendor/aprs-symbols-${sheet}.png')`), `missing sheet ${sheet}`);
}
assert.match(css, /\.aprs-symbol-overlaid\s*\{[^}]*aprs-symbols-24-2\.png'\), url\('\/vendor\/aprs-symbols-24-1\.png'\)/);
});
@@ -6,10 +6,12 @@ import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("APRS entry normalizes positioned packets without remote symbol assets", async () => {
const forwarded = [];
const window = {
...createHost(),
trxUi: { confirm: async () => true },
aprsMapAddStation: (...args) => { forwarded.push(args); },
};
@@ -38,3 +40,49 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as
assert.equal(forwarded[0][1], 54.5);
assert.equal(forwarded[0][6].rig_id, null);
});
// Distance is computed from the receiver position and the great-circle helper,
// both of which the entry reads through the host contract. These used to be
// bare window properties that the module graph stopped publishing, so the
// distance column rendered empty for every positioned packet.
test("APRS distance text uses the receiver position from the host contract", async () => {
const distanceArgs = [];
const host = createHost({
state: { serverLat: 54.0, serverLon: 18.0 },
core: {
haversineKm: (lat1, lon1, lat2, lon2) => {
distanceArgs.push([lat1, lon1, lat2, lon2]);
return 61.7;
},
},
});
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
const node = () => ({
innerHTML: "", textContent: "", className: "", style: {}, dataset: {},
appendChild(child) { return child; }, addEventListener() {}, replaceChildren() {},
querySelector: () => null, querySelectorAll: () => [], setAttribute() {},
classList: { add() {}, toggle() {} },
});
const context = vm.createContext({
window,
document: {
getElementById: (id) => (id === "aprs-packets" ? node() : null),
querySelectorAll: () => [],
createElement: node,
createDocumentFragment: node,
},
navigator: {},
Date, Number, String, Array, Set, Reflect, console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await bundleEntry(new URL("../src/plugins/aprs.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
window.trxPluginRuntime.dispatch("aprs", {
src_call: "SP0ABC", dest_call: "APRS", crc_ok: true, lat: 54.5, lon: 18.5,
symbol_table: "/", symbol_code: ">",
});
assert.deepEqual(distanceArgs, [[54.0, 18.0, 54.5, 18.5]]);
});
@@ -3,13 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { createHost } from "./host-fixture.mjs";
import { bundleEntry } from "./bundle-entry.mjs";
test("background decode loads configuration for the explicitly selected rig", async () => {
const requested = [];
const window = {
...createHost(),
decoderRegistry: [],
trxUi: { confirm: async () => true },
};
@@ -35,7 +37,7 @@ test("background decode loads configuration for the explicitly selected rig", as
Error,
console,
});
const source = await readFile(new URL("../../assets/web/generated/background-decode.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/background-decode.ts", import.meta.url));
new vm.Script(source).runInContext(context);
window.trx.modules.backgroundDecode.initialize("rig/a", "control");
@@ -3,9 +3,9 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
class ElementFixture {
constructor() {
@@ -26,6 +26,49 @@ class ElementFixture {
querySelector() { return null; }
}
// Mirrors the `window.trx` host contract published by app.ts. The plugin is a
// separate bundle, so every application service it uses arrives this way.
function hostFixture(overrides = {}) {
const calls = { postPath: [], setRigFrequency: [], armOptimisticFrequency: [], applyLocalTunedFrequency: [], syncBandwidthInput: [], scheduleSpectrumDraw: 0, syncModePicker: 0 };
const state = {
authEnabled: false,
authRole: "control",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
lastFreqHz: 14_074_000,
lastModeName: "USB",
lastSpectrumData: null,
currentBandwidthHz: 2400,
decoderRegistry: [],
...overrides,
};
const core = {
postPath: async (path) => { calls.postPath.push(path); },
setRigFrequency: (hz) => { calls.setRigFrequency.push(hz); },
applyLocalTunedFrequency: (hz, force) => { calls.applyLocalTunedFrequency.push([hz, force]); },
armOptimisticFrequency: (hz) => { calls.armOptimisticFrequency.push(hz); },
syncBandwidthInput: (hz) => { calls.syncBandwidthInput.push(hz); },
scheduleSpectrumDraw: () => { calls.scheduleSpectrumDraw += 1; },
syncModePicker: () => { calls.syncModePicker += 1; },
onDecoderRegistryReady: () => {},
};
return { window: { trx: { state, core, modules: {} }, trxUi: { confirm: async () => true } }, calls };
}
function documentFixture(element) {
return {
getElementById: element,
querySelector: () => new ElementFixture(),
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createTextNode: (text) => ({ textContent: text }),
addEventListener() {},
};
}
const source = await bundleEntry(new URL("../src/plugins/bookmarks.ts", import.meta.url));
test("bookmarks register an explicit typed service for application consumers", async () => {
const elements = new Map();
const element = (id) => {
@@ -36,33 +79,15 @@ test("bookmarks register an explicit typed service for application consumers", a
element("bm-category-filter").options.push({ value: "" });
element("bm-mode-filter").options.push({ value: "" });
const bookmarks = [{ id: "one", name: "Local", freq_hz: 145_500_000, mode: "FM", scope: "general" }];
const window = {
trx: { modules: {} },
trxUi: { confirm: async () => true },
decoderRegistry: [],
};
const { window } = hostFixture();
const context = vm.createContext({
window,
document: {
getElementById: element,
querySelector: () => new ElementFixture(),
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
createTextNode: (text) => ({ textContent: text }),
addEventListener() {},
},
document: documentFixture(element),
fetch: async () => ({ ok: true, json: async () => bookmarks }),
CSS: { escape: (value) => value },
Element: ElementFixture,
Set,
Map,
Array,
Number,
String,
Promise,
console,
});
const source = await readFile(new URL("../../assets/web/generated/bookmarks.js", import.meta.url), "utf8");
new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve));
@@ -73,3 +98,80 @@ test("bookmarks register an explicit typed service for application consumers", a
assert.equal(typeof window.trx.modules.bookmarks.populateScopePicker, "function");
assert.equal(globalThis.bmOverlayList, undefined);
});
test("applying a bookmark drives tuning through the host services", async () => {
const elements = new Map();
const element = (id) => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window, calls } = hostFixture({
lastActiveRigId: "sdr",
decoderRegistry: [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"], bookmark_selectable: true },
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW"], bookmark_selectable: true },
],
});
const context = vm.createContext({
window,
document: documentFixture(element),
fetch: async (url) => ({
ok: true,
json: async () => (url.startsWith("/status") ? { ft8_decode_enabled: false, cw_decode_enabled: true } : []),
}),
CSS: { escape: (value) => value },
Element: ElementFixture,
console,
});
new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve));
calls.postPath.length = 0;
window.trx.modules.bookmarks.apply({
id: "ft8-20m", name: "FT8 20m", freq_hz: 14_074_000, mode: "USB", bandwidth_hz: 3000, decoders: ["ft8"],
});
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
// Optimistic UI: the guard is armed before the display update so a stale SSE
// frame cannot snap the marker back while the tune is in flight.
assert.deepEqual(calls.armOptimisticFrequency, [14_074_000]);
assert.deepEqual(calls.applyLocalTunedFrequency, [[14_074_000, true]]);
assert.deepEqual(calls.syncBandwidthInput, [3000]);
assert.equal(window.trx.state.currentBandwidthHz, 3000);
assert.equal(element("mode").value, "USB");
// Rig commands: mode and bandwidth over postPath, frequency via the core
// tuning service so virtual-channel redirection still applies.
assert.deepEqual(calls.setRigFrequency, [14_074_000]);
assert.ok(calls.postPath.includes("/set_mode?mode=USB"));
assert.ok(calls.postPath.includes("/set_bandwidth?hz=3000"));
// ft8 is selected and off; cw is on but incompatible with USB.
assert.ok(calls.postPath.includes("/toggle_ft8_decode"));
assert.ok(calls.postPath.includes("/toggle_cw_decode"));
});
test("bookmark controls follow the host authentication state", async () => {
const elements = new Map();
const element = (id) => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const { window } = hostFixture({ authEnabled: true, authRole: "rx" });
const context = vm.createContext({
window,
document: documentFixture(element),
fetch: async () => ({ ok: true, json: async () => [] }),
CSS: { escape: (value) => value },
Element: ElementFixture,
console,
});
new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(element("bm-add-btn").style.display, "none");
window.trx.state.authRole = "control";
await window.trx.modules.bookmarks.fetch("");
assert.equal(element("bm-add-btn").style.display, "");
});
@@ -3,167 +3,18 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx: false,
filter_controls: false,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
// page.evaluate callbacks run in the browser, not in this Node process.
/* global document, getComputedStyle, window, location */
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", []],
["/rigs", rigsResponse],
["/status", {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["FM"],
num_vfos: 1,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx: false,
tx_limit: false,
vfo_switch: false,
filter_controls: false,
signal_meter: false,
},
},
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: null, lock: null },
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: false,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
}],
["/bandplan.json", {}],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
function assetPath(urlPath) {
if (urlPath === "/") return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
jsonRoutes.get("/status").active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
if (url.pathname === "/audio") {
response.writeHead(404).end();
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": contentTypes.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert(address && typeof address === "object");
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
const fixture = await startWebFixture();
const { selectedRigs } = fixture;
const { browser, page, runtimeErrors } = await startBrowser(chromium);
try {
await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: "domcontentloaded" });
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.waitForTimeout(500);
assert.deepEqual(runtimeErrors, []);
await page.locator("#content").waitFor({ state: "visible" });
@@ -172,6 +23,130 @@ try {
await page.locator("summary", { hasText: "Audio controls" }).click();
assert.equal(await page.locator("#rx-audio-btn").count(), 1);
// Digital modes: the decoders are a list down the side, and the panel for the
// selected one sits beside it. A horizontal strip put thirteen decoders in a
// scroller and marked the open one with a single underline among them.
await page.locator('.tab[data-tab="digital-modes"]').click();
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
await page.locator('.sub-tab[data-subtab="ft8"]').click();
await page.waitForTimeout(250);
const digital = await page.evaluate(() => {
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar").getBoundingClientRect();
const panel = document.getElementById("subtab-ft8").getBoundingClientRect();
const shown = [...document.querySelectorAll("#tab-digital-modes > .sub-tab-panel")]
.filter((element) => getComputedStyle(element).display !== "none")
.map((element) => element.id);
return {
sidebarIsColumn: bar.height > bar.width,
panelBesideSidebar: Math.round(panel.left) >= Math.round(bar.right),
panelsShown: shown,
decoders: document.querySelectorAll("#tab-digital-modes > .sub-tab-bar .sub-tab").length,
};
});
assert.ok(digital.sidebarIsColumn, "the decoder list is not a sidebar");
assert.ok(digital.panelBesideSidebar, "the decoder panel does not sit beside the sidebar");
assert.deepEqual(digital.panelsShown, ["subtab-ft8"], `panels shown: ${JSON.stringify(digital.panelsShown)}`);
assert.ok(digital.decoders >= 10, `only ${digital.decoders} decoders in the sidebar`);
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Map links from decode rows, before anything has opened the Map tab. The
// lazy map module used to install these globals itself, so an AIS pin threw
// "not a function" and an APRS link silently did nothing until the tab had
// been visited once.
const mapLinkReady = await page.evaluate(() => ({
position: typeof window.navigateToAprsMap,
locator: typeof window.navigateToMapLocator,
mapModuleLoaded: !!window.trx.modules.map,
}));
assert.equal(mapLinkReady.position, "function", "navigateToAprsMap is missing before the map loads");
assert.equal(mapLinkReady.locator, "function", "navigateToMapLocator is missing before the map loads");
assert.equal(mapLinkReady.mapModuleLoaded, false, "the map module was already loaded, so this proves nothing");
await page.evaluate(() => { window.navigateToAprsMap(52.2, 21.0); });
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
await page.waitForTimeout(500);
const followed = await page.evaluate(() => ({
path: location.pathname,
active: [...document.querySelectorAll(".tab-bar .tab.active")].map((tab) => tab.dataset.tab || tab.id),
mapHeight: Math.round(document.getElementById("aprs-map").getBoundingClientRect().height),
}));
assert.equal(followed.path, "/map", `the map link left the page on ${followed.path}`);
assert.ok(followed.active.includes("map"), `the strip marks ${JSON.stringify(followed.active)}`);
assert.ok(followed.mapHeight > 100, `the map came up ${followed.mapHeight}px tall`);
await page.locator('.tab[data-tab="main"]').click();
await page.waitForTimeout(200);
// Section order in the tray. "Advanced radio controls" is built at runtime,
// so it lands wherever ui-core puts it rather than where the markup says —
// appending, as it once did, always left it last.
const sections = await page.evaluate(() =>
[...document.querySelectorAll(".controls-tray > details")].map((section) =>
section.querySelector("summary").textContent.trim()));
assert.deepEqual(sections, ["Advanced radio controls", "Audio controls", "Scheduler controls"],
`tray sections are ${JSON.stringify(sections)}`);
// Mode is a button group over a hidden <select>, which stays the value a
// dozen call sites and several plugins read. The click has to reach it, and
// the select must not take part in layout while it does.
const modeBefore = await page.evaluate(() => ({
buttons: document.querySelectorAll("#mode-picker button").length,
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
}));
assert.ok(modeBefore.buttons > 1, `mode picker rendered ${modeBefore.buttons} buttons`);
assert.equal(modeBefore.active, modeBefore.value, "mode picker disagrees with the select");
const target = await page.evaluate(() => {
const other = [...document.querySelectorAll("#mode-picker button")]
.find((btn) => btn.dataset.mode !== document.getElementById("mode").value);
return other?.dataset.mode;
});
await page.locator(`#mode-picker button[data-mode="${target}"]`).click();
await page.waitForTimeout(200);
const modeAfter = await page.evaluate(() => ({
value: document.getElementById("mode").value,
active: document.querySelector("#mode-picker button.active")?.dataset.mode,
selectWidth: Math.round(document.getElementById("mode").getBoundingClientRect().width),
}));
assert.equal(modeAfter.value, target, `clicking ${target} left the select at ${modeAfter.value}`);
assert.equal(modeAfter.active, target, "the clicked mode is not the marked one");
assert.ok(modeAfter.selectWidth <= 2, `the hidden select still occupies ${modeAfter.selectWidth}px`);
// Mode-specific controls live on their own row, which has to leave with them:
// an empty one would still take a track and a gap in the tray and draw its
// divider under the controls every rig has.
const modeRowState = async () => page.evaluate(() => {
const row = document.getElementById("mode-controls-row");
return { display: getComputedStyle(row).display, height: Math.round(row.getBoundingClientRect().height) };
});
await page.locator('#mode-picker button[data-mode="WFM"]').click();
await page.waitForTimeout(250);
const withWfm = await modeRowState();
assert.notEqual(withWfm.display, "none", "WFM controls did not bring their row up");
assert.ok(withWfm.height > 0, `WFM row has no height (${withWfm.height}px)`);
await page.locator('#mode-picker button[data-mode="FM"]').click();
await page.waitForTimeout(250);
const withoutWfm = await modeRowState();
assert.equal(withoutWfm.display, "none", "the mode row stayed behind with nothing in it");
// Scheduler controls read left to right: step, hand back, then the entry on
// air. The separator is drawn by the current-entry block, so it can only sit
// in the right place if that block is last.
const schedulerRow = await page.evaluate(() => [...document.querySelectorAll(".scheduler-action-row > *")]
.map((el) => el.id || [...el.children].map((c) => c.id).join("+")));
assert.deepEqual(schedulerRow,
["scheduler-prev-btn+scheduler-next-btn", "scheduler-release-btn", "scheduler-cycle-status"],
`scheduler control order is ${JSON.stringify(schedulerRow)}`);
// The footer status pill colours its dot from data-state, so a hint written
// straight to textContent would leave the dot stuck on the previous state.
const hint = await page.evaluate(() => {
const element = document.getElementById("power-hint");
return { state: element.dataset.state, text: element.textContent.trim() };
});
assert.ok(["ok", "busy", "error"].includes(hint.state), `status pill state is ${hint.state}`);
assert.equal(hint.state, "ok", `fixture reports "${hint.text}" but the pill is ${hint.state}`);
const rigPicker = page.locator("#header-rig-switch-select");
await rigPicker.locator("option").nth(1).waitFor({ state: "attached" });
await rigPicker.selectOption("rig-b");
@@ -181,10 +156,51 @@ try {
await page.locator('.tab[data-tab="map"]').click();
await page.locator("#aprs-map .leaflet-pane").first().waitFor({ state: "attached" });
assert.equal(new URL(page.url()).pathname, "/map");
// The selected destination is marked by a box on all four sides, so a rule
// that drops one edge (the mobile nav used to lose its bottom border) is a
// regression even though the tab still reads as "active".
const activeTab = await page.evaluate(() => {
const style = getComputedStyle(document.querySelector(".tab-bar-nav .tab.active"));
return ["Top", "Right", "Bottom", "Left"].map((side) => ({
width: style.getPropertyValue(`border-${side.toLowerCase()}-width`),
color: style.getPropertyValue(`border-${side.toLowerCase()}-color`),
}));
});
for (const edge of activeTab) {
assert.notEqual(edge.width, "0px", `active tab border: ${JSON.stringify(activeTab)}`);
assert.ok(!/rgba\(0, 0, 0, 0\)|transparent/.test(edge.color), `active tab border: ${JSON.stringify(activeTab)}`);
}
// The map is full-bleed: it breaks out of the centred .card column and
// reaches both viewport edges, without pushing the page sideways.
const stage = await page.evaluate(() => {
const rect = document.getElementById("map-stage").getBoundingClientRect();
return {
left: Math.round(rect.left),
right: Math.round(rect.right),
viewport: document.documentElement.clientWidth,
sideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
gapToFooter: Math.round(document.querySelector(".footer").getBoundingClientRect().top - rect.bottom),
pageScrolls: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1,
};
});
assert.equal(stage.left, 0, `map stage starts at ${stage.left}px, not the viewport edge`);
assert.equal(stage.right, stage.viewport, `map stage ends at ${stage.right}px, not ${stage.viewport}px`);
assert.equal(stage.sideways, false, "full-bleed map makes the page scroll sideways");
// ...and fills the column down to the footer. Capping the height at a
// fraction of the viewport left a dead band that grew with the window.
assert.ok(stage.gapToFooter <= 16,
`${stage.gapToFooter}px of dead space between the map and the footer`);
assert.equal(stage.pageScrolls, false, "the map grew past the viewport");
await page.locator('.tab[data-tab="main"]').click();
assert.equal(new URL(page.url()).pathname, "/");
await page.locator('.tab[data-tab="about"]').click();
// About is an occasional destination, so it lives behind More at every
// width rather than in the operating tab strip.
await page.locator("#mobile-more-btn").click();
await page.locator('[data-navigate-tab="about"]').click();
await page.locator("#tab-about").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/about");
@@ -192,7 +208,138 @@ try {
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.equal(new URL(page.url()).pathname, "/");
assert.deepEqual(runtimeErrors, []);
// Refreshing or deep-linking must mark the destination, not Tools. The first
// route navigation runs while the card is still behind the loading state, so
// a test that asked whether the tab was displayed saw "none" for every tab
// and lit Tools up on every refresh of every page.
for (const [route, tab, toolsLit] of [["/map", "map", false], ["/about", "about", true]]) {
await page.goto(`${fixture.origin}${route}`, { waitUntil: "domcontentloaded" });
await page.locator(`#tab-${tab}`).waitFor({ state: "visible" });
const marked = await page.evaluate(() => ({
actives: [...document.querySelectorAll(".tab-bar .tab.active")].map((t) => t.dataset.tab || t.id),
tools: document.getElementById("mobile-more-btn").classList.contains("active"),
}));
assert.ok(marked.actives.includes(tab), `${route} marks ${JSON.stringify(marked.actives)}`);
assert.equal(marked.tools, toolsLit, `${route}: Tools active is ${marked.tools}`);
}
await page.goto(`${fixture.origin}/`, { waitUntil: "domcontentloaded" });
await page.locator("#tab-main").waitFor({ state: "visible" });
assert.deepEqual(runtimeErrors, []);
// --- Layout regressions -------------------------------------------------
// Every fault below shipped at some point while the rest of this file
// passed, because nothing here looked at geometry: a header whose height
// tracked the viewport, controls that stretched, a tab strip that ran under
// the controls, and a dropdown that opened underneath the spectrum.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(250);
const header = await page.evaluate(() => {
const bar = document.querySelector(".tab-bar");
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const controls = [...actions.children]
.filter((el) => !el.hidden && el.getBoundingClientRect().height > 0)
.map((el) => Math.round(el.getBoundingClientRect().height));
return {
barHeight: Math.round(bar.getBoundingClientRect().height),
// The tabs, not the strip: with the strip allowed to overflow its box
// shrinks while its content paints across the controls, so the
// container's own rect never registers the collision.
overlap: Math.round(Math.max(...[...nav.querySelectorAll(".tab")]
.filter((tab) => tab.offsetParent !== null)
.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
heights: [...new Set(controls)],
pageScrollsSideways: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
};
});
assert.ok(header.barHeight <= 96, `header is ${header.barHeight}px at ${width}px; it should stay one row`);
assert.ok(header.overlap <= 0, `tab strip overlaps the controls by ${header.overlap}px at ${width}px`);
assert.ok(header.heights.length <= 2, `controls have heights ${header.heights.join(", ")} at ${width}px`);
assert.equal(header.pageScrollsSideways, false, `page scrolls sideways at ${width}px`);
}
// A dropdown must paint over the page, not inside the header: fixed
// positioning escapes clipping but not the header's stacking context.
await page.setViewportSize({ width: 1280, height: 900 });
await page.waitForTimeout(250);
const menu = await page.evaluate(() => {
document.getElementById("mobile-more-btn").click();
const element = document.getElementById("mobile-more-menu");
const rect = element.getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
return { width: Math.round(rect.width), height: Math.round(rect.height), onTop: element.contains(hit) };
});
assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`);
assert.ok(menu.onTop, "menu is painted underneath the page");
// Wider text than this machine renders. This suite passed on macOS twice
// while CI, whose system font is wider, put the tab strip into the controls —
// the second time by 9px at 1440px with no scaling at all, because the fit
// test was an arithmetic estimate whose fixed allowance for the gaps did not
// cover them at those metrics. A single scale factor cannot stand in for
// another platform's fonts, so sweep: somewhere in this range is whatever CI
// renders, and the strip has to hold at every step of it.
const applyTextScale = (scale) => page.addStyleTag({ content: `
.tab-bar .title { font-size: ${1.05 * scale}rem !important; }
.tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; }
.tab-bar .tab { font-size: ${0.95 * scale}rem !important; }
.tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; }
` });
const measureBar = () => page.evaluate(() => {
const nav = document.querySelector(".tab-bar-nav");
const actions = document.querySelector(".top-bar-actions");
const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null);
return {
overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right))
- actions.getBoundingClientRect().left),
iconsOnly: nav.classList.contains("nav-icons-only"),
};
});
for (const scale of [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) {
await applyTextScale(scale);
// 1100px is the narrowest bar in the app: the bookmark gutters take 9.5rem
// a side above that width, leaving less room than 900px has.
for (const width of [1440, 1280, 1100, 900]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(120);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`at ${scale}x text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
}
// A station name long enough that the bar cannot hold it, which is the rung
// below icons: the identity has to give, not the strip.
await page.evaluate(() => {
document.getElementById("rig-subtitle").textContent =
"Rig: Shack SDR — RTL-SDR v4 on the attic dipole, north-west";
});
await applyTextScale(1.6);
for (const width of [1440, 1100]) {
await page.setViewportSize({ width, height: 900 });
await page.waitForTimeout(200);
const crowded = await measureBar();
assert.ok(crowded.overlap <= 0,
`with a long station name the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`);
}
// ...and when the text changes under a bar that is not resized. The rig name
// arrives from the server, a web font swaps in: neither is a window resize,
// and the strip used to sit there as it was.
await page.setViewportSize({ width: 1440, height: 900 });
await page.waitForTimeout(200);
await applyTextScale(2.4);
await page.waitForTimeout(400);
const unresized = await measureBar();
assert.ok(unresized.overlap <= 0,
`text grew without a resize and the strip overlaps by ${unresized.overlap}px`);
assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them");
} finally {
await browser.close();
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await fixture.close();
}
@@ -4,7 +4,7 @@
import { build } from "esbuild";
export async function bundleEntry(entryUrl) {
export async function bundleEntry(entryUrl, globalName) {
const result = await build({
entryPoints: [entryUrl.pathname],
bundle: true,
@@ -12,6 +12,7 @@ export async function bundleEntry(entryUrl) {
platform: "browser",
target: "es2022",
write: false,
...(globalName ? { globalName } : {}),
});
const output = result.outputFiles[0];
if (!output) throw new Error(`No bundle output for ${entryUrl.pathname}`);
@@ -3,10 +3,10 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
class ElementFixture {
constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; }
@@ -21,7 +21,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a
const output = new ElementFixture();
const status = new ElementFixture();
const elements = new Map([["cw-output", output], ["cw-status", status]]);
const window = { trxUi: { confirm: async () => true }, addEventListener() {} };
const window = { ...createHost(), trxUi: { confirm: async () => true }, addEventListener() {} };
const context = vm.createContext({
window,
document: {
@@ -39,7 +39,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a
console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/cw.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -6,11 +6,13 @@ import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => {
let barRenderer;
const mapMessages = [];
const window = {
...createHost(),
ft8BaseHz: 14_074_000,
trxUi: { confirm: async () => true },
ft8ExtractAllGrids: () => ["JO91"],
@@ -46,6 +48,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async (
test("FT8 entry installs shared parsing without relying on script globals", async () => {
const forwarded = [];
const window = {
...createHost(),
ft8BaseHz: 7_074_000,
trxUi: { confirm: async () => true },
mapAddLocator: (...args) => { forwarded.push(args); },
@@ -6,9 +6,10 @@ import assert from "node:assert/strict";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("HF APRS entry uses shared typed normalization and local symbols", async () => {
const window = { trxUi: { confirm: async () => true } };
const window = { ...createHost(), trxUi: { confirm: async () => true } };
const context = vm.createContext({
window,
document: { getElementById: () => null, querySelectorAll: () => [] },
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Mirrors the `window.trx` host contract that app.ts publishes and that
// src/plugins/host.ts consumes. Feature bundles read all application state
// and services through it, so every plugin test needs it on its stub window.
export function createHost({ state = {}, core = {}, modules = {} } = {}) {
const calls = [];
const record = (name, result) => (...args) => {
calls.push({ name, args });
return typeof result === "function" ? result(...args) : result;
};
return {
calls,
trx: {
state: {
serverLat: null,
serverLon: null,
authEnabled: false,
authRole: "control",
lastActiveRigId: null,
lastRigIds: [],
lastRigDisplayNames: {},
lastFreqHz: null,
lastSpectrumData: null,
jogUnit: 1000,
rxActive: false,
decoderRegistry: [],
lastModeName: "",
currentBandwidthHz: 2400,
audioChannelOverride: null,
...state,
},
core: {
postPath: record("postPath", async () => undefined),
applyLocalTunedFrequency: record("applyLocalTunedFrequency"),
armOptimisticFrequency: record("armOptimisticFrequency"),
escapeMapHtml: (value) => String(value)
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;"),
haversineKm: record("haversineKm", () => 0),
showHint: record("showHint"),
formatFreqForStep: (hz) => String(hz),
refreshFreqDisplay: record("refreshFreqDisplay"),
setJogDivisor: record("setJogDivisor"),
mwDefaultsForMode: record("mwDefaultsForMode", () => [0, 0, 0, 0]),
resetRdsDisplay: record("resetRdsDisplay"),
positionRdsPsOverlay: record("positionRdsPsOverlay"),
updateWfmControls: record("updateWfmControls"),
updateSdrSquelchControlVisibility: record("updateSdrSquelchControlVisibility"),
updateDocumentTitle: record("updateDocumentTitle"),
activeChannelRds: record("activeChannelRds", () => null),
startRxAudio: record("startRxAudio"),
stopRxAudio: record("stopRxAudio"),
setRigFrequency: record("setRigFrequency"),
syncBandwidthInput: record("syncBandwidthInput"),
scheduleSpectrumDraw: record("scheduleSpectrumDraw"),
syncModePicker: record("syncModePicker"),
onDecoderRegistryReady: record("onDecoderRegistryReady"),
...core,
},
modules,
},
};
}
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => {
const overlays = [];
const window = {
...createHost(),
trxUi: { confirm: async () => true },
addSatMapOverlay: (image) => { overlays.push(image); },
};
@@ -28,7 +29,7 @@ test("satellite entry registers lifecycle callbacks and forwards georeferenced i
console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/sat.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/sat.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,12 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { createHost } from "./host-fixture.mjs";
import { bundleEntry } from "./bundle-entry.mjs";
test("scheduler registers a typed module service without lifecycle globals", async () => {
const window = { trx: { modules: {} }, trxUi: { confirm: async () => true } };
// No role known yet: the entry registers its service and waits for the
// application to drive initialization.
const window = { ...createHost({ state: { authRole: null } }), trxUi: { confirm: async () => true } };
const context = vm.createContext({
window,
document: {
@@ -31,7 +34,7 @@ test("scheduler registers a typed module service without lifecycle globals", asy
WeakSet,
console,
});
const source = await readFile(new URL("../../assets/web/generated/scheduler.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/scheduler.ts", import.meta.url));
new vm.Script(source).runInContext(context);
const service = window.trx.modules.scheduler;
@@ -41,3 +44,70 @@ test("scheduler registers a typed module service without lifecycle globals", asy
assert.equal(window.initScheduler, undefined);
assert.equal(window.schedulerBridge, undefined);
});
// Lazy-load case: the settings tab is opened after boot, so the application has
// already passed initSettingsUI() and the entry must initialize itself from the
// host state. This read used to come from a bare window property that the
// module graph stopped publishing, so the scheduler never started.
test("scheduler self-initializes for the active rig when a role is already known", async () => {
class ElementFixture {
constructor() {
this.value = "";
this.textContent = "";
this.innerHTML = "";
this.style = {};
this.dataset = {};
this.options = [];
this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
this.children = [];
}
addEventListener() {}
appendChild(child) { this.children.push(child); return child; }
remove() {}
focus() {}
closest() { return null; }
querySelector() { return null; }
querySelectorAll() { return []; }
}
const elements = new Map();
const fetched = [];
const element = (id) => {
if (!elements.has(id)) elements.set(id, new ElementFixture());
return elements.get(id);
};
const window = {
...createHost({ state: { authRole: "control", lastActiveRigId: "sdr" } }),
trxUi: { confirm: async () => true },
};
const context = vm.createContext({
window,
document: {
activeElement: null,
addEventListener() {},
getElementById: element,
querySelector: () => new ElementFixture(),
querySelectorAll: () => [],
createElement: () => new ElementFixture(),
},
localStorage: { getItem: () => null, setItem() {} },
fetch: async (url) => { fetched.push(String(url)); return { ok: true, json: async () => ({}) }; },
setInterval: () => 1,
clearInterval() {},
setTimeout: () => 1,
HTMLElement: ElementFixture,
Element: ElementFixture,
Date,
Number,
String,
Array,
Promise,
WeakSet,
console,
});
const source = await bundleEntry(new URL("../src/plugins/scheduler.ts", import.meta.url));
new vm.Script(source).runInContext(context);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(fetched.some((url) => url.includes("scheduler") && url.includes("sdr")),
`expected a scheduler load for the active rig, saw ${JSON.stringify(fetched)}`);
});
@@ -0,0 +1,224 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Geometry of the spectrum area while tuning. Everything above the spectrum is
// driven by what happens to be in the visible range — band plan allocations,
// bookmarks — and the strips that show them used to appear and disappear with
// it, so tuning across a band edge moved the whole page under the operator's
// cursor. Nothing else in the suite serves a rig with a spectrum.
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
/* global document, getComputedStyle */
const BOOKMARKS = [
{ id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" },
{ id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" },
];
const BANDPLAN = {
iaru1: {
bands: [{
name: "40m",
low_hz: 7000000,
high_hz: 7200000,
segments: [
{ low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" },
{ low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" },
],
}],
},
};
// 40m has both bookmarks and allocations; 20m has neither.
const BAND_WITH_CONTENT = 7074000;
const BAND_WITHOUT_CONTENT = 14074000;
// The meter sits well away from the spectrum's noise floor, so a squelch that
// took its level from the plot would land somewhere else entirely.
const METER_DB = -70;
const fixture = await startWebFixture({
spectrum: true,
bookmarks: BOOKMARKS,
bandplan: BANDPLAN,
bandplanEnabled: true,
meterDb: METER_DB,
});
const { browser, page, runtimeErrors } = await startBrowser(chromium);
function readGeometry() {
const top = (selector) => {
const el = document.querySelector(selector);
return el ? Math.round(el.getBoundingClientRect().top) : null;
};
const axis = document.getElementById("spectrum-bookmark-axis");
const strip = document.getElementById("spectrum-bandplan-strip");
return {
chips: axis.querySelectorAll(".spectrum-bookmark-chip").length,
axisEmpty: axis.classList.contains("bm-axis-empty"),
stripReserved: strip.classList.contains("bp-visible"),
stripEmpty: strip.classList.contains("bp-empty"),
overviewTop: top(".overview-strip"),
spectrumTop: top("#spectrum-panel"),
controlsTop: top(".controls-row"),
footerTop: top(".footer"),
docHeight: document.documentElement.scrollHeight,
};
}
const layoutOf = (geometry) => ({
overviewTop: geometry.overviewTop,
spectrumTop: geometry.spectrumTop,
controlsTop: geometry.controlsTop,
footerTop: geometry.footerTop,
docHeight: geometry.docHeight,
});
async function tuneTo(hz) {
fixture.setCenterHz(hz);
await page.waitForTimeout(900);
return page.evaluate(readGeometry);
}
try {
await page.setViewportSize({ width: 1600, height: 950 });
await page.goto(fixture.origin, { waitUntil: "domcontentloaded" });
await page.locator("#content").waitFor({ state: "visible" });
await page.locator("#spectrum-panel").waitFor({ state: "visible" });
await page.waitForTimeout(1500);
const populated = await tuneTo(BAND_WITH_CONTENT);
assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`);
assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it");
assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations");
assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it");
const bare = await tuneTo(BAND_WITHOUT_CONTENT);
assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`);
assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder");
assert.equal(bare.stripReserved, true, "band plan strip gave its height back");
assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty");
// The point of both placeholders: tuning must not move anything.
assert.deepEqual(layoutOf(bare), layoutOf(populated),
`tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`);
const back = await tuneTo(BAND_WITH_CONTENT);
assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back");
assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page");
// The rail covers the top of the overview, so only the chips may take
// pointer events — the rest has to fall through to the plot behind it.
const hits = await page.evaluate(() => {
const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip");
const chipRect = chip.getBoundingClientRect();
const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect();
const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2);
const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10);
return {
chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName),
besideChip: besideChip?.id || besideChip?.tagName,
};
});
assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`);
assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`);
// Squelch: the threshold is in the dB the spectrum axis is labelled in, so the
// line is the control. It used to be a percentage on a slider in the audio
// row, with nothing on screen to relate the number to.
await page.locator("summary", { hasText: "Audio controls" }).click();
await page.locator("#sdr-squelch-toggle").click();
// Auto parks it just above the noise the meter reports — not above the
// spectrum's noise floor, which sits anywhere from 1 dB below to 22 dB above
// the meter depending on span, bandwidth and decimation.
await page.locator("#sdr-squelch-auto").click();
await page.waitForTimeout(400);
const auto = await page.evaluate(() => Number(document.getElementById("sdr-squelch-db").value));
assert.ok(Math.abs(auto - (METER_DB + 5)) <= 1,
`auto put the threshold at ${auto} dB with the meter at ${METER_DB} dB`);
const squelchOn = await page.evaluate(() => {
const line = document.getElementById("spectrum-squelch-line");
return {
shown: getComputedStyle(line).display !== "none",
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
toggle: document.getElementById("sdr-squelch-toggle").getAttribute("aria-pressed"),
top: Math.round(line.getBoundingClientRect().top),
};
});
assert.equal(squelchOn.shown, true, "the threshold line did not appear with the squelch on");
assert.equal(squelchOn.toggle, "true", "the SQL switch did not follow the squelch state");
assert.equal(squelchOn.label, squelchOn.db, "the line and the readout disagree on the threshold");
// Dragging the line down lowers the threshold and tells the server.
const submitted = [];
page.on("request", (request) => {
if (request.url().includes("/set_sdr_squelch")) {
submitted.push(Number(new URL(request.url()).searchParams.get("threshold_db")));
}
});
const grip = await page.locator("#spectrum-squelch-grip").boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2 + 60, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(400);
const dragged = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
label: Number(document.getElementById("spectrum-squelch-label").textContent),
top: Math.round(document.getElementById("spectrum-squelch-line").getBoundingClientRect().top),
}));
assert.ok(dragged.db < squelchOn.db,
`dragging down left the threshold at ${dragged.db} dB (was ${squelchOn.db})`);
assert.equal(dragged.label, dragged.db, "the line label did not follow the drag");
assert.ok(dragged.top > squelchOn.top, "the line did not move with the drag");
assert.ok(submitted.includes(dragged.db),
`the server was never told about ${dragged.db} dB (saw ${JSON.stringify(submitted)})`);
// Turning it off leaves the threshold alone — the old control conflated the
// two, so dropping to zero to listen threw the setting away.
await page.locator("#sdr-squelch-toggle").click();
await page.waitForTimeout(300);
const squelchOff = await page.evaluate(() => ({
db: Number(document.getElementById("sdr-squelch-db").value),
shown: getComputedStyle(document.getElementById("spectrum-squelch-line")).display !== "none",
dot: document.getElementById("sdr-squelch-state").dataset.state,
}));
assert.equal(squelchOff.db, dragged.db, "turning the squelch off discarded the threshold");
assert.equal(squelchOff.shown, false, "the line stayed up with the squelch off");
assert.equal(squelchOff.dot, "off", "the indicator did not follow the squelch off");
assert.deepEqual(runtimeErrors, []);
} finally {
await browser.close();
await fixture.close();
}
// The band plan is fetched once at startup, which can land before the session
// exists. It used to fail silently and never retry, so the allocations only
// turned up if the operator reloaded the page by hand.
const retryFixture = await startWebFixture({
spectrum: true,
bandplan: BANDPLAN,
bandplanEnabled: true,
bandplanUnauthorizedFirst: true,
});
const retry = await startBrowser(chromium);
try {
await retry.page.setViewportSize({ width: 1600, height: 950 });
await retry.page.goto(retryFixture.origin, { waitUntil: "domcontentloaded" });
await retry.page.locator("#spectrum-panel").waitFor({ state: "visible" });
await retry.page.waitForTimeout(2000);
const strip = await retry.page.evaluate(() => {
const element = document.getElementById("spectrum-bandplan-strip");
return { segments: element.children.length, empty: element.classList.contains("bp-empty") };
});
assert.ok(strip.segments > 0,
"the band plan never arrived after its first request was refused");
assert.equal(strip.empty, false, "the strip is still showing its placeholder");
} finally {
await retry.browser.close();
await retryFixture.close();
}
@@ -143,4 +143,31 @@ ui.applyLayout("digital");
assert.equal(document.body.dataset.operatorLayout, "compact");
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
// A layout seeds the collapsible sections but must not hold them there:
// applyLayout runs on every rig-state refresh, so an operator who expands a
// section a layout collapses by default had it shut again about once a second.
const schedulerSection = new Element("details", document);
schedulerSection.id = "scheduler-controls";
const audioSection = new Element("details", document);
audioSection.id = "audio-controls";
ui.setActiveRig("rig-c");
ui.applyLayout("compact");
assert.equal(schedulerSection.open, false, "compact seeds the scheduler section closed");
schedulerSection.open = true; // operator expands it
ui.applyLayout("compact"); // the next rig-state refresh
assert.equal(schedulerSection.open, true, "re-applying the same layout leaves sections alone");
ui.setActiveRig("rig-c");
assert.equal(schedulerSection.open, true, "a rig-state refresh leaves sections alone");
// Choosing a different layout is an explicit request for its defaults.
ui.applyLayout("full");
assert.equal(schedulerSection.open, true, "full seeds the scheduler section open");
assert.equal(audioSection.open, true, "full seeds the audio section open");
audioSection.open = false;
ui.applyLayout("compact");
assert.equal(audioSection.open, false, "compact seeds the audio section closed");
assert.equal(schedulerSection.open, false, "switching layout reseeds every section");
console.log("ui-core component tests passed");
@@ -3,12 +3,13 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { createHost } from "./host-fixture.mjs";
import { bundleEntry } from "./bundle-entry.mjs";
test("virtual channels expose typed SSE and interception boundaries", async () => {
const window = {};
const window = { ...createHost() };
const context = vm.createContext({
window,
document: { getElementById: () => null },
@@ -25,7 +26,7 @@ test("virtual channels expose typed SSE and interception boundaries", async () =
Error,
console,
});
const source = await readFile(new URL("../../assets/web/generated/vchan.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/vchan.ts", import.meta.url));
new vm.Script(source).runInContext(context);
const service = window.trx.modules.vchan;
@@ -34,6 +35,8 @@ test("virtual channels expose typed SSE and interception boundaries", async () =
assert.equal(typeof service.applyCapabilities, "function");
assert.equal(await service.interceptMode("USB"), false);
assert.equal(await service.interceptBandwidth(2400), false);
// No virtual channel is active, so tuning stays with the physical rig.
assert.equal(service.interceptFrequency(14_074_000), false);
assert.equal(window.vchanHandleSession, undefined);
assert.equal(window.vchanInterceptBandwidth, undefined);
});
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("VDES entry normalizes and forwards positioned server messages", async () => {
const forwarded = [];
const window = {
...createHost(),
trxUi: { confirm: async () => true },
vdesMapAddPoint: (message) => { forwarded.push(message); },
};
@@ -24,7 +25,7 @@ test("VDES entry normalizes and forwards positioned server messages", async () =
console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/vdes.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -0,0 +1,277 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// The static server the browser tests load the real web assets from. It was
// inline in browser-smoke.mjs until a second browser test needed a rig with a
// spectrum: the interesting layout lives in the spectrum area, and none of it
// could be exercised while the only fixture served a CAT-only rig.
import { readFile } from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const webDir = path.resolve(frontendDir, "../assets/web");
const generatedDir = path.join(webDir, "generated");
// A realistic decoder registry. Serving an empty one hid most of the
// application from this test: the decoder sub-tabs, their panels, the decode
// toggles and the bookmark decoder checkboxes are all built from it, so with
// no decoders only three of thirteen sub-tabs existed and none of the decoder
// UI was ever constructed.
const DECODER_REGISTRY = [
{ id: "ft8", label: "FT8", activation: "toggle", active_modes: ["USB"] },
{ id: "ft4", label: "FT4", activation: "toggle", active_modes: ["USB"] },
{ id: "ft2", label: "FT2", activation: "toggle", active_modes: ["USB"] },
{ id: "wspr", label: "WSPR", activation: "toggle", active_modes: ["USB"] },
{ id: "cw", label: "CW", activation: "toggle", active_modes: ["CW", "CWR"] },
{ id: "sat", label: "SAT", activation: "toggle", active_modes: ["FM"] },
{ id: "wefax", label: "WEFAX", activation: "toggle", active_modes: ["USB"] },
{ id: "ais", label: "AIS", activation: "toggle", active_modes: ["FM"] },
{ id: "aprs", label: "APRS", activation: "toggle", active_modes: ["FM"] },
{ id: "hf-aprs", label: "HF APRS", activation: "toggle", active_modes: ["USB"] },
{ id: "vdes", label: "VDES", activation: "toggle", active_modes: ["FM"] },
].map((decoder) => ({ ...decoder, background_decode: false, bookmark_selectable: true }));
const CONTENT_TYPES = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "application/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".woff2", "font/woff2"],
]);
function assetPath(urlPath) {
// Every tab route has its own index handler on the server (see api/assets.rs),
// so a deep link or a refresh serves the SPA shell, not a 404.
if (!path.extname(urlPath)) return path.join(webDir, "index.html");
if (urlPath.startsWith("/vendor/")) return path.join(webDir, urlPath);
const generated = path.join(generatedDir, path.basename(urlPath));
if (urlPath.endsWith(".js")) return generated;
return path.join(webDir, urlPath);
}
/**
* Starts the fixture server.
*
* `spectrum` turns the rig into an SDR: `filter_controls` is what gates the
* spectrum panel, and frames are pushed on the /spectrum stream from a centre
* frequency the test moves with `setCenterHz` to simulate tuning across bands.
*/
export async function startWebFixture({
spectrum = false,
tx = false,
meterDb = -70,
bookmarks = [],
bandplan = {},
bandplanEnabled = false,
bandplanUnauthorizedFirst = false,
} = {}) {
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
remote,
display_name: remote === "rig-a" ? "Primary fixture" : "Secondary fixture",
manufacturer: "Smoke",
model: "Fixture",
supported_modes: ["FM"],
tx,
filter_controls: spectrum,
initialized: true,
latitude: null,
longitude: null,
}));
const rigsResponse = { rigs: rigItems, active_remote: "rig-a" };
const selectedRigs = [];
const state = { centerHz: 7074000 };
let bandplanServed = false;
const status = {
info: {
manufacturer: "Smoke",
model: "Fixture",
revision: "1",
access: { Tcp: { addr: "127.0.0.1:0" } },
capabilities: {
min_freq_step_hz: 1,
supported_bands: [],
supported_modes: ["LSB", "USB", "CW", "CWR", "AM", "SAM", "WFM", "FM", "AIS", "VDES", "DIG", "PKT"],
num_vfos: 1,
lock: false,
lockable: tx,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
tx,
tx_limit: tx,
vfo_switch: false,
filter_controls: spectrum,
signal_meter: spectrum,
},
},
status: { freq: { hz: 100_000_000 }, mode: "FM", tx_en: false, vfo: null, tx: null, rx: { sig: meterDb }, lock: null },
// Reported only by SDR backends, and what makes the client show the
// squelch control at all.
filter: spectrum
? {
bandwidth_hz: 12_000,
sdr_squelch_enabled: false,
sdr_squelch_threshold_db: -95,
sdr_agc_enabled: false,
}
: null,
band: null,
enabled: true,
initialized: true,
cw_auto: false,
cw_wpm: 20,
cw_tone_hz: 700,
aprs_decode_enabled: false,
hf_aprs_decode_enabled: false,
cw_decode_enabled: false,
ft8_decode_enabled: false,
ft4_decode_enabled: false,
ft2_decode_enabled: false,
wspr_decode_enabled: false,
lrpt_decode_enabled: false,
wefax_decode_enabled: false,
recorder_enabled: false,
clients: 1,
rigctl_clients: 0,
audio_clients: 0,
active_remote: "rig-a",
remotes: ["rig-a", "rig-b"],
show_sdr_gain_control: false,
initial_map_zoom: 10,
spectrum_coverage_margin_hz: 50_000,
spectrum_usable_span_ratio: 0.92,
bandplan_enabled: bandplanEnabled,
bandplan_region: "iaru1",
decode_history_retention_min: 1440,
server_connected: true,
};
const jsonRoutes = new Map([
["/auth/session", { authenticated: true, role: "control", auth_disabled: true }],
["/decoders", DECODER_REGISTRY],
["/rigs", rigsResponse],
["/status", status],
["/bookmarks", bookmarks],
["/bandplan.json", bandplan],
["/api/recorder/status", []],
["/api/recorder/files", []],
]);
// Flat i8 bins: the shape does not matter, only that frames arrive so the
// page has a spectrum range to place bookmarks and allocations against.
const spectrumBins = Buffer.alloc(512, 200);
const spectrumB64 = spectrumBins.toString("base64");
const server = http.createServer(async (request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
if (url.pathname === "/select_rig" && request.method === "POST") {
const remote = url.searchParams.get("remote");
if (remote) {
rigsResponse.active_remote = remote;
status.active_remote = remote;
selectedRigs.push(remote);
}
response.writeHead(200).end();
return;
}
// Rejects the first band plan request the way the server did before it was
// classified as a public asset: the page asks for it at startup, which can
// land before the session exists.
if (bandplanUnauthorizedFirst && url.pathname === "/bandplan.json" && !bandplanServed) {
bandplanServed = true;
response.writeHead(401).end();
return;
}
if (jsonRoutes.has(url.pathname)) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(jsonRoutes.get(url.pathname)));
return;
}
// 200 means "audio is configured": the client hides the whole audio row —
// and the squelch control with it — when this 404s.
if (url.pathname === "/audio") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ sample_rate: 48_000, channels: 1 }));
return;
}
if (spectrum && url.pathname === "/spectrum") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`event: b\ndata: ${state.centerHz},192000,${spectrumB64}\n\n`);
}, 100);
request.on("close", () => clearInterval(timer));
return;
}
// The meter streams like the server's does: the squelch reads its noise
// level from here, so a static snapshot would leave it nothing to measure.
if (url.pathname === "/meter") {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
const timer = setInterval(() => {
response.write(`data: ${JSON.stringify({ sig: meterDb })}\n\n`);
}, 120);
request.on("close", () => clearInterval(timer));
return;
}
if (["/events", "/decode", "/spectrum", "/meter"].includes(url.pathname)) {
response.writeHead(200, {
"cache-control": "no-cache",
connection: "keep-alive",
"content-type": "text/event-stream",
});
response.write(": browser smoke stream\n\n");
return;
}
try {
const file = assetPath(url.pathname);
const bytes = await readFile(file);
response.writeHead(200, {
"content-type": CONTENT_TYPES.get(path.extname(file)) ?? "application/octet-stream",
});
response.end(bytes);
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address !== "object") throw new Error("fixture server has no port");
return {
origin: `http://127.0.0.1:${address.port}`,
selectedRigs,
rigsResponse,
/** Moves the spectrum centre, i.e. tunes the fixture rig to another band. */
setCenterHz(hz) { state.centerHz = hz; },
close() {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
},
};
}
/** Launches headless Chromium and records any uncaught page error. */
export async function startBrowser(chromium) {
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH
?? "/usr/bin/chromium";
const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox"] });
const page = await browser.newPage();
const runtimeErrors = [];
page.on("pageerror", (error) => runtimeErrors.push(error.stack ?? error.message));
return { browser, page, runtimeErrors };
}
@@ -3,14 +3,14 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => {
const status = { textContent: "", style: { color: "" } };
const window = {};
const window = { ...createHost() };
const context = vm.createContext({
window,
document: { getElementById: (id) => id === "wefax-status" ? status : null },
@@ -21,7 +21,7 @@ test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", a
console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/wefax.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/wefax.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -3,14 +3,15 @@
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
import { bundleEntry } from "./bundle-entry.mjs";
import { createHost } from "./host-fixture.mjs";
test("WSPR entry forwards decoded locators at their absolute frequency", async () => {
const forwarded = [];
const window = {
...createHost(),
ft8BaseHz: 14_095_600,
trxUi: { confirm: async () => true },
mapAddLocator: (...args) => { forwarded.push(args); },
@@ -27,7 +28,7 @@ test("WSPR entry forwards decoded locators at their absolute frequency", async (
console,
});
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
const source = await readFile(new URL("../../assets/web/generated/wspr.js", import.meta.url), "utf8");
const source = await bundleEntry(new URL("../src/plugins/wspr.ts", import.meta.url));
new vm.Script(runtime).runInContext(context);
new vm.Script(source).runInContext(context);
@@ -95,6 +95,18 @@ pub(crate) async fn about_index(req: HttpRequest) -> impl Responder {
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/statistics")]
pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
#[get("/bookmarks")]
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
let c = gz_index_html();
static_asset_response(&req, "text/html; charset=utf-8", c)
}
// ---------------------------------------------------------------------------
// Favicon & logo
// ---------------------------------------------------------------------------
@@ -267,6 +279,47 @@ pub(crate) async fn leaflet_layers_2x() -> impl Responder {
.body(status::LEAFLET_LAYERS_2X)
}
// ---------------------------------------------------------------------------
// Vendored APRS symbol sprites
// ---------------------------------------------------------------------------
fn embedded_png(bytes: &'static [u8]) -> HttpResponse {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.insert_header((header::CACHE_CONTROL, "public, max-age=604800, immutable"))
.body(bytes)
}
#[get("/vendor/aprs-symbols-24-0.png")]
pub(crate) async fn aprs_symbols_primary() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_PRIMARY)
}
#[get("/vendor/aprs-symbols-24-0-2x.png")]
pub(crate) async fn aprs_symbols_primary_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_PRIMARY_2X)
}
#[get("/vendor/aprs-symbols-24-1.png")]
pub(crate) async fn aprs_symbols_alternate() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_ALTERNATE)
}
#[get("/vendor/aprs-symbols-24-1-2x.png")]
pub(crate) async fn aprs_symbols_alternate_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_ALTERNATE_2X)
}
#[get("/vendor/aprs-symbols-24-2.png")]
pub(crate) async fn aprs_symbols_overlay() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_OVERLAY)
}
#[get("/vendor/aprs-symbols-24-2-2x.png")]
pub(crate) async fn aprs_symbols_overlay_2x() -> impl Responder {
embedded_png(status::APRS_SYMBOLS_OVERLAY_2X)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -295,4 +348,41 @@ mod tests {
);
assert_eq!(generated_content_type("secret.txt"), None);
}
/// Reads the width and height out of a PNG IHDR chunk.
fn png_dimensions(bytes: &[u8]) -> (u32, u32) {
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "not a PNG");
assert_eq!(&bytes[12..16], b"IHDR", "first chunk is not IHDR");
let width = u32::from_be_bytes(bytes[16..20].try_into().expect("IHDR width"));
let height = u32::from_be_bytes(bytes[20..24].try_into().expect("IHDR height"));
(width, height)
}
/// The browser computes sprite cell offsets from a 16x6 grid of 24px cells,
/// so a sheet at any other size would silently shift every APRS symbol.
#[test]
fn aprs_symbol_sheets_match_the_sprite_grid_the_frontend_assumes() {
for (name, bytes) in [
("aprs-symbols-24-0", status::APRS_SYMBOLS_PRIMARY),
("aprs-symbols-24-1", status::APRS_SYMBOLS_ALTERNATE),
("aprs-symbols-24-2", status::APRS_SYMBOLS_OVERLAY),
] {
assert_eq!(
png_dimensions(bytes),
(384, 144),
"{name} is not a 16x6 grid"
);
}
for (name, bytes) in [
("aprs-symbols-24-0-2x", status::APRS_SYMBOLS_PRIMARY_2X),
("aprs-symbols-24-1-2x", status::APRS_SYMBOLS_ALTERNATE_2X),
("aprs-symbols-24-2-2x", status::APRS_SYMBOLS_OVERLAY_2X),
] {
assert_eq!(
png_dimensions(bytes),
(768, 288),
"{name} is not a 2x sheet"
);
}
}
}
@@ -637,6 +637,8 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::recorder_index)
.service(assets::settings_index)
.service(assets::about_index)
.service(assets::statistics_index)
.service(assets::bookmarks_index)
.service(assets::favicon)
.service(assets::favicon_png)
.service(assets::logo)
@@ -656,6 +658,13 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(assets::leaflet_marker_shadow)
.service(assets::leaflet_layers)
.service(assets::leaflet_layers_2x)
// Vendored APRS symbol sprites
.service(assets::aprs_symbols_primary)
.service(assets::aprs_symbols_primary_2x)
.service(assets::aprs_symbols_alternate)
.service(assets::aprs_symbols_alternate_2x)
.service(assets::aprs_symbols_overlay)
.service(assets::aprs_symbols_overlay_2x)
.service(assets::generated_asset)
// Virtual channels
.service(vchan::list_channels)
@@ -504,7 +504,14 @@ impl RouteAccess {
return Self::Public;
}
// Static assets
// Static assets. The band plan is one of them: it is compiled into the
// binary and identical for every user, but ".json" is not an asset
// suffix, so it used to fall through to Control — leaving read-only
// users without a band plan, and everyone else without one whenever the
// page requested it before the session was established.
if path == "/bandplan.json" {
return Self::Public;
}
if path.starts_with("/style.css")
|| path.starts_with("/app.js")
|| path.ends_with(".js")
@@ -696,6 +703,12 @@ mod tests {
assert_eq!(RouteAccess::from_path("/auth/logout"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/style.css"), RouteAccess::Public);
assert_eq!(RouteAccess::from_path("/app.js"), RouteAccess::Public);
// Static reference data, served to every role: ".json" is not in the
// asset suffix list, so this one has to be named.
assert_eq!(
RouteAccess::from_path("/bandplan.json"),
RouteAccess::Public
);
}
#[test]
@@ -37,6 +37,23 @@ pub const LEAFLET_MARKER_SHADOW: &[u8] = include_bytes!("../assets/web/vendor/ma
pub const LEAFLET_LAYERS: &[u8] = include_bytes!("../assets/web/vendor/layers.png");
pub const LEAFLET_LAYERS_2X: &[u8] = include_bytes!("../assets/web/vendor/layers-2x.png");
// Vendored APRS symbol sprites (https://github.com/hessu/aprs-symbols).
// Each sheet is a 16x6 grid of 24px cells indexed by `symbol code - 0x21`:
// table 0 is the primary ('/') set, table 1 the alternate ('\') set, and
// table 2 the overlay characters drawn on top of an alternate symbol.
pub const APRS_SYMBOLS_PRIMARY: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0.png");
pub const APRS_SYMBOLS_PRIMARY_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-0-2x.png");
pub const APRS_SYMBOLS_ALTERNATE: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-1.png");
pub const APRS_SYMBOLS_ALTERNATE_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-1-2x.png");
pub const APRS_SYMBOLS_OVERLAY: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-2.png");
pub const APRS_SYMBOLS_OVERLAY_2X: &[u8] =
include_bytes!("../assets/web/vendor/aprs-symbols-24-2-2x.png");
/// Build version tag used for cache-busting asset URLs and ETag headers.
/// Computed once from `PKG_VERSION` + `CLIENT_BUILD_DATE`.
pub fn build_version_tag() -> &'static str {
@@ -837,12 +837,6 @@ impl ChannelDsp {
}
}
let signal_power = decimated
.iter()
.map(|s| s.re * s.re + s.im * s.im)
.sum::<f32>()
/ decimated.len() as f32;
let signal_db = 10.0 * signal_power.max(1e-12).log10();
const WFM_OUTPUT_GAIN: f32 = 0.50;
let mut audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
let mut out = decoder.process_iq(decimated);
@@ -884,7 +878,14 @@ impl ChannelDsp {
raw
}
};
if !self.squelch.update(&self.mode, signal_db) {
// Against the meter reading, not the block level after the IQ AGC.
// The threshold arrives in the scale the operator sets it from — the
// S-meter and the spectrum — while the level measured here had been
// through the AGC, whose whole job is to hold it at a setpoint. For
// every mode that has one (FM, PKT, AIS, AM, SAM) the squelch was
// therefore comparing against a near-constant, and for the rest it was
// still off by the decimation correction the meter applies.
if !self.squelch.update(&self.mode, self.last_signal_db) {
audio.fill(0.0);
}
@@ -933,6 +934,93 @@ mod tests {
dsp.process_block(&block);
}
/// Feeds one signal twice, with the squelch threshold set from the channel's
/// own meter reading: 6 dB above it must gate the audio, 6 dB below it must
/// pass. FM runs an IQ AGC, so a squelch measured after that stage compares
/// against a level pinned near the AGC setpoint — some 20 dB adrift of the
/// scale the operator reads the threshold off, and open on plain noise.
#[test]
fn squelch_follows_the_meter_the_threshold_is_set_from() {
const AMPLITUDE: f32 = 0.0025;
let (pcm_tx, mut pcm_rx) = broadcast::channel::<Vec<f32>>(4096);
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(8);
let mut dsp = ChannelDsp::new(
0.0,
&RigMode::FM,
48_000,
8_000,
1,
20,
12_000,
75,
true,
false,
VirtualSquelchConfig::default(),
NoiseBlankerConfig::default(),
pcm_tx,
iq_tx,
);
// A 1 kHz tone on the carrier, so an open gate is audibly non-zero and
// a closed one is unambiguously silent.
let mut phase = 0.0_f32;
let mut mod_phase = 0.0_f32;
let mut feed = |dsp: &mut ChannelDsp, blocks: usize| {
for _ in 0..blocks {
let mut block = Vec::with_capacity(4096);
for _ in 0..4096 {
mod_phase += std::f32::consts::TAU * 1_000.0 / 48_000.0;
phase += std::f32::consts::TAU * (3_000.0 * mod_phase.sin()) / 48_000.0;
block.push(Complex::new(
AMPLITUDE * phase.cos(),
AMPLITUDE * phase.sin(),
));
}
dsp.process_block(&block);
}
};
let drain = |rx: &mut broadcast::Receiver<Vec<f32>>| {
let mut audio = Vec::new();
while let Ok(frame) = rx.try_recv() {
audio.extend_from_slice(&frame);
}
audio
};
let peak = |audio: &[f32]| audio.iter().fold(0.0_f32, |acc, s| acc.max(s.abs()));
// Settle the meter on this signal, then read what the operator would.
feed(&mut dsp, 24);
let meter_db = dsp.signal_db();
assert!(
meter_db > -120.0,
"the meter never moved off its floor ({meter_db} dB)"
);
dsp.set_squelch(true, meter_db + 6.0);
let _ = drain(&mut pcm_rx);
feed(&mut dsp, 24);
let gated = drain(&mut pcm_rx);
assert!(!gated.is_empty(), "no audio frames were produced at all");
// From the second half on: the first frame out still carries the audio
// that was already buffered when the threshold changed.
assert_eq!(
peak(&gated[gated.len() / 2..]),
0.0,
"squelch set 6 dB above the meter ({meter_db} dB) still passed audio"
);
dsp.set_squelch(true, meter_db - 6.0);
let _ = drain(&mut pcm_rx);
feed(&mut dsp, 24);
let passed = drain(&mut pcm_rx);
assert!(!passed.is_empty(), "no audio frames were produced at all");
assert!(
peak(&passed[passed.len() / 2..]) > 0.0,
"squelch set 6 dB below the meter ({meter_db} dB) gated the audio"
);
}
#[test]
fn channel_dsp_set_mode() {
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);