Compare commits

...
Author SHA1 Message Date
sjg 09634eb851 [fix](trx-frontend-http): tidy up the map's filter bar
CI / lint (pull_request) Successful in 2m21s
CI / test (pull_request) Successful in 8m7s
CI / frontend (push) Successful in 2m57s
CI / reuse (push) Successful in 3s
CI / frontend (pull_request) Successful in 3m47s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 7m19s
The bar explained itself in prose: "All bands visible by default" sat
between the chips and the next group, taking width the bar could not
spare and reading as a stray line of text. An "All" chip says the same
thing in a chip's width and gives the selection somewhere to be undone.

Band chips also came up dimmed at the very moment every band was on the
map -- an empty selection is no filter at all, so nothing is dimmed
until something is picked. The path toggles drop their "On"/"Off"
suffix, which cost most of a row and only repeated what their own
highlight already said; state moves to aria-pressed and the tooltip.

The rest is alignment. The rule dividing the buttons from the filters
is drawn on the button block's edge, and a centred block left it
floating as a stub beside a two-row bar; stacked, it lay down the left
of a block that sits underneath. The labels sat at their natural
widths, so each row's first control started somewhere different, and
the two pairs of phase buttons differed in width, so the groups after
them missed each other by four pixels. One gutter for every label, one
width for both pairs, and the search field moved last where it can take
the room the fixed-width groups leave.

The map layout test now covers the chips, the divider's height and the
rows' shared start.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 21:43:57 +02:00
sjg 90ab7781ad [fix](trx-frontend-http): stop the browser offering saved values for frequency
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m11s
CI / frontend (pull_request) Successful in 3m48s
CI / reuse (pull_request) Successful in 2s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m23s
CI / frontend (push) Successful in 2m54s
CI / reuse (push) Successful in 3s
The tuned and centre frequency readouts are text inputs, so the browser
keeps what has been typed into them and offers it back in a dropdown --
Edge does this out of the box, dropping stale frequencies from other
sessions over the reading.

Turn autofill off on both, along with autocorrect and spellcheck, which
have no business near a number either.

Fixes #39

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:36:22 +02:00
sjg 88d04253ca [fix](trx-frontend-http): move the map's fullscreen and filter toggles into the bar
Fullscreen and Hide Filters floated in their own block over the map's
top-right corner, separate from the filter bar they sit beside.

Put them at the right-hand end of the bar, behind a separator. What made
this awkward before is that Hide Filters cannot live inside the thing it
hides, so the collapse now applies to the filters alone: the bar keeps its
two controls and shrinks to them at the map's right edge, leaving the whole
map visible and the way back one click away.

Fixes #38

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 19:34:09 +02:00
sjg 08005c5c07 [feat](trx-frontend-http): lay the map filters out as a bar across the top
CI / test (push) Successful in 8m6s
CI / frontend (push) Successful in 3m45s
CI / reuse (push) Successful in 3s
CI / lint (push) Successful in 2m15s
The filters were a 30rem column parked in the bottom-right corner, covering
a third of the map they filter. Lay them out horizontally instead: one row
per group -- label beside its control, thin rules between -- across the top
of the map, spanning ~87% of its width at 1600px and wrapping to a second
row as it narrows.

It starts clear of Leaflet's zoom buttons and stops short of the corner
controls, which stay outside it: the button that hides the filters cannot
live inside the thing it hides. The bottom-left band legend keeps its place.
The sentence explaining the two path toggles would have swallowed the bar,
so it moves to their tooltips and is shown inline only in the stacked
narrow-screen layout.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:54:21 +02:00
sjg 026f816ddb [fix](trx-frontend-http): replay stored decodes onto the map when it loads
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 2s
The map module is lazy: it arrives when the Map tab is first opened, which
is normally long after startup restored the decode history. Until then
aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and
the decoders' `if (lat != null && ... && fn)` guards quietly dropped every
restored position. Nothing replayed them once the module did arrive, so the
map came up empty and filled in only from decodes heard afterwards -- a
station heard once was never plotted at all. A second reload appeared to
fix it because the cached module then loaded early enough to win the race
against the history fetch.

Give DecoderPlugin an optional syncMap(), implement it for APRS, AIS and
VDES over the history each already retains, and have map-core call
trxPluginRuntime.syncMapAll() as it attaches. The add functions are keyed
by callsign, MMSI and point, so replaying updates in place and cannot
duplicate a marker; the replay runs oldest-first so tracks are rebuilt in
the order they happened.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:34:45 +02:00
sjg d31b6f545f [test](trx-frontend-http): watch the history progress from inside the page
CI / lint (push) Successful in 2m16s
CI / test (push) Successful in 8m18s
CI / frontend (push) Successful in 3m38s
CI / reuse (push) Successful in 2s
The replay-progress check polled the overlay from the test every 100ms.
A replay that starts and finishes between two polls is never sampled, and
the test then reports that no progress was shown at all -- the source of
the intermittent "no progress was shown while the history loaded" failure.

Record the samples from a MutationObserver installed before the page's own
scripts run, so a fast replay is observed rather than missed.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:57 +02:00
sjg 39f551c914 [fix](trx-frontend-http): hold the APRS symbol column open for frames without one
renderLocalAprsSymbol() returns nothing when a packet carries no symbol
table or code, so those rows lost the icon's 24px slot and every column
after it -- callsign, type badge, summary -- slid left against the rows
around them. Frames that do carry a symbol then read as indented.

Render an empty slot of the same size instead, so a list mixing position
reports with messages and telemetry still lines up.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-05 07:10:33 +02:00
sjg 6d25ecdc11 [feat](trx-frontend-http): give the AIS list the same log shape
CI / lint (push) Successful in 2m15s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A message was three stacked lines — time and name, then MMSI and route,
then motion, distance, position and age — so a screen held eight of
them.  It is one line now: time, vessel, message type, and what the
message says, opening in place for the MMSI, the channel frequency, the
route, the age, the fix and a jump to the map.  Twenty-two fit where
eight did.

What a message says depends on what it is.  Position reports give the
fix and the motion; the static and voyage reports that carry no fix give
the callsign and where the vessel is bound.  Both fall back to whatever
fields are present rather than showing nothing.

The row vocabulary the APRS list introduced is no longer APRS-specific —
the classes are decode-line and decode-expanded now, shared by both, and
identity sits in fixed columns so the summaries line up down the list
instead of starting wherever the callsign happens to end.  The three
summary cards above the list go the way of the APRS ones.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:24:09 +02:00
sjg 37987b2779 [feat](trx-frontend-http): make the APRS list a log, and read the payloads
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s
A frame was a card five lines tall — timestamp, a meta line, the
information field as it arrived on the air, three buttons, and a Details
panel repeating the four things already on the row — so five frames
filled the panel and the payload was left to be decoded by eye.

A frame is one line now: time, station, type, and what the frame says.
It opens in place for the path, the CRC, the raw field, its bytes and
the actions.  Twenty-one frames fit where five did.

And the information field is read rather than echoed.  Weather reports
give temperature, wind, humidity and pressure; telemetry gives its
sequence and channels; a message gives its addressee and text; a
position gives the fix, course and speed, and the comment the station
wrote.  Anything that cannot be summarised falls back to the raw field,
which is in the expanded view either way.

HF APRS had a copy of the same forty lines of markup, differing by one
badge, so both now build their rows from one function in the shared
module — the CSS is shared between them and this would have broken it
otherwise.  Both headers lose their three summary cards for a line of
counts beside the filters, which frees another fifth of the panel.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 23:12:02 +02:00
sjg 67a7bace4e [fix](trx-frontend-http): let the decode lists fill their panel
CI / test (push) Successful in 8m16s
CI / lint (push) Successful in 2m18s
CI / frontend (push) Successful in 3m40s
CI / reuse (push) Successful in 3s
FT8, FT4, FT2 and WSPR size their list against the panel with flex, and
the sidebar layout made the panel a grid item aligned to the start of
its row — sized to its own content.  The lists collapsed to their 120px
minimum with several hundred pixels of the page empty underneath.  The
panel stretches to the row now and the sidebar keeps its own height.

The marine lists were sized a different way, by formula: 100vh minus a
guess at everything above them.  That guess stopped matching the moment
the panel changed shape, so they left a few hundred pixels unused as
well.  They fill the panel like the rest now, and so does CW, which had
a 360px ceiling.

HF APRS had no container styling at all — no scroller, no frame, no
height — so its packets ran down the page.  It gets what the other
packet lists have.

The smoke test measures each list against its panel and requires it to
scroll on its own.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:49:28 +02:00
sjg b48cc23d6e [fix](trx-frontend-http): keep the rig names through the state stream
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m14s
CI / frontend (push) Successful in 3m47s
CI / reuse (push) Successful in 3s
The picker and the header showed each rig's lowercase id instead of its
configured name.  applyRigList takes the names as a parameter defaulted
to an empty map, and the state-update path passes only the rig ids —
names come from /rigs, not from a state frame — so that call landed on
the default and the body, which treats "an object" as "here are the
names", cleared them.  One frame after load the names were gone for the
rest of the session.  Omitted now means no news rather than no names.

The fixture is why this was invisible: it pushed an identical status
payload every tick and the client skips a frame equal to the last, so
render never ran and neither did the call that did the damage.  Its
event stream varies between frames now, as a real one does.

Which immediately caught a second fault: state frames arrive
continuously, and one sent before the server applied a new squelch
threshold snapped the line back to where it had just been dragged from.
A local change outranks the echo for two seconds, the same idea as the
optimistic frequency guard beside it.  The fixture also records what
/set_sdr_squelch sets and reports it back afterwards — the drag test had
been passing against a server that ignored the write.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:38:21 +02:00
sjg c1899229a0 [fix](trx-frontend-http): stop the decode history replay giving up at 20s
CI / lint (push) Successful in 2m19s
CI / test (push) Successful in 8m15s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 3s
Reloading a second time sometimes showed history the first load did not,
and the safety valve is why: it called one function that both released
the buffered live decodes and tore the history worker down, so any load
where the replay had not finished inside twenty seconds — a large
backlog, a cold cache, a slow link — dropped whatever had not arrived,
without a word.  A reload got another go at it, and the second one is
faster because everything is cached by then.

Those are two separate things now.  At the timeout the live decodes are
released so the panels are not held back, the replay carries on, and the
progress says so.  The fallback's error path retries once and then says
"Decode history unavailable" rather than leaving the operator to guess
whether there was anything to see.

The progress is no longer a scrim.  It was fixed to the whole viewport
with a wash over the page — the waterfall, the decode panels, all of it —
for the length of the replay, which is exactly when there is something
worth watching.  It is a corner card with a bar: indeterminate while the
payload is on the wire, then filling as N of M messages replay.

None of this was reachable from a test.  /decode/history answers in CBOR
and the worker reads the body as CBOR unconditionally, but the fixture
served JSON, so every browser run had been exercising the client's retry
path and never its history path.  It encodes CBOR now, including the
64-bit form the millisecond timestamps need, and decode-flow serves 1200
records and holds the client to restoring all of them on the first load,
showing progress while it does, and never covering the page with it.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 22:14:13 +02:00
sjg 84a99a3636 [fix](trx-frontend-http): load the decoders that own the digital modes panels
AIS, VDES and both APRS decoders were listed under the map plugin group
alone, so opening Digital modes and clicking AIS or APRS gave an empty
panel reading "Connected, listening for packets" while the decodes piled
up unprocessed in the plugin runtime.  They appeared only if something
had opened the Map tab first, which flushed the queue.  There is also a
map-data group naming exactly those four that nothing loads: the loader
is called with tab names and no tab is called map-data.

They load with the tab whose panels they fill now.  map-core stays lazy,
since their calls into it are optional and the Map tab can go on paying
for Leaflet by itself.

tests/decode-flow.mjs follows a decode from the wire to the map: an AIS
vessel and an APRS beacon arrive on /decode, and it asserts both panels
fill with the map module confirmed absent, the mini view names the
vessel and offers a pin, following that pin lands on /map centred on the
vessel, and both decoders leave a marker.  Nothing exercised any of this
before — the fixture served an empty decode stream, which is how the map
links came to be broken for every decoder at once.

The fixture stamps decodes as it sends them, since the client prunes
anything outside the retention window, and repeats them, since the views
collapse by vessel and need more than one frame to behave.

Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-04 21:31:30 +02:00
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
sjg 695434942f chore: complete TypeScript migration cleanup
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / frontend (pull_request) Failing after 42s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 1s
CI / frontend (push) Failing after 44s
CI / reuse (push) Failing after 2s
2026-08-01 22:39:37 +02:00
sjg a1a8d1d1d3 test: cover lazy map and rig startup flows 2026-08-01 22:34:22 +02:00
sjg ddb33b6ff3 feat: generate frontend status metadata contract 2026-08-01 22:27:35 +02:00
sjg f87289b129 fix: validate typed frontend startup in Chromium 2026-08-01 22:25:10 +02:00
sjg 812359c744 refactor: enforce typed frontend runtime boundaries 2026-08-01 22:19:50 +02:00
sjg d929ad3bde build: consolidate typed frontend module graph 2026-08-01 21:54:00 +02:00
sjg 8aeca613aa test: add frontend browser startup smoke coverage 2026-08-01 20:19:27 +02:00
sjg ee59d8efdd build: remove JavaScript compatibility mode 2026-08-01 19:28:45 +02:00
sjg ddd151c1ee refactor: convert main frontend application to TypeScript 2026-08-01 19:18:48 +02:00
sjg 5136704826 refactor: extract typed spectrum math 2026-08-01 18:19:02 +02:00
sjg e300cc343d refactor: extract typed auto bandwidth estimator 2026-08-01 17:59:04 +02:00
sjg 1fb196a64e refactor: extract typed navigation routes 2026-08-01 17:18:41 +02:00
sjg c8cc235e88 refactor: extract strict CBOR decoder 2026-08-01 17:04:13 +02:00
sjg ab3fe4bbb7 refactor: extract typed frontend formatters 2026-08-01 16:42:54 +02:00
sjg 40accb9697 refactor: extract typed authentication API 2026-08-01 16:39:52 +02:00
sjg cbca810a30 refactor: extract typed decoder registry 2026-08-01 16:28:39 +02:00
sjg 26a167cb82 refactor: replace feature globals with typed services 2026-08-01 16:05:40 +02:00
sjg 3d49839ed5 build: type-check worker with isolated globals 2026-08-01 15:21:16 +02:00
sjg 26be675242 build: enable frontend module code splitting 2026-08-01 14:40:59 +02:00
sjg 1a42d58075 refactor: embed generated frontend asset directory 2026-08-01 14:39:34 +02:00
sjg 844de809a4 fix: preserve bandplan override lookup 2026-08-01 14:23:29 +02:00
sjg e910e644d5 refactor: extract typed frontend core utilities 2026-08-01 13:48:51 +02:00
sjg 50e07e2715 refactor: add typed frontend bootstrap 2026-08-01 13:43:46 +02:00
sjg 82121491c5 refactor: convert map feature to strict TypeScript 2026-08-01 13:39:41 +02:00
sjg 0338c8b8d2 refactor: convert scheduler to typed module 2026-08-01 13:23:30 +02:00
sjg c7994347e9 refactor: convert bookmarks to typed module 2026-08-01 13:13:52 +02:00
sjg 9fef0ebc7b refactor: route all decoders through plugin runtime 2026-08-01 13:06:38 +02:00
sjg 508cf2ba87 refactor: add typed decoder plugin runtime 2026-08-01 13:02:54 +02:00
sjg 6a83e2e90a refactor: convert virtual channels to TypeScript 2026-08-01 12:57:09 +02:00
sjg fc2f55bab9 refactor: convert satellite scheduler to TypeScript 2026-08-01 12:54:03 +02:00
sjg 4fb65971e4 refactor: convert satellite view to TypeScript 2026-08-01 12:51:22 +02:00
sjg 021d31d780 refactor: share typed APRS plugin contracts 2026-08-01 12:48:33 +02:00
sjg 074c67c7b9 refactor: convert APRS plugin to TypeScript 2026-08-01 12:43:40 +02:00
sjg d93f784aad refactor: convert AIS plugin to TypeScript 2026-08-01 12:39:20 +02:00
sjg ec59908e0d refactor: convert background decode controls to TypeScript 2026-08-01 12:36:27 +02:00
sjg 7251ec276d refactor: convert WEFAX plugin to TypeScript 2026-08-01 12:33:38 +02:00
sjg 0ab0f80986 refactor: convert VDES plugin to TypeScript 2026-08-01 12:30:17 +02:00
sjg 2cbcc23fec refactor: move plugin loading into typed registry 2026-08-01 12:26:29 +02:00
sjg 968fb3ea2d refactor: type the local Leaflet AIS adapter 2026-08-01 12:23:37 +02:00
sjg 5ba3ecf59b refactor: make FT8 family a typed module 2026-08-01 12:21:13 +02:00
sjg 03bdc10b02 refactor: convert CW plugin to TypeScript 2026-08-01 12:18:51 +02:00
sjg d96624be4f refactor: convert WSPR plugin to TypeScript 2026-08-01 12:15:19 +02:00
sjg a5dccd5489 refactor: consolidate FT2 and FT4 plugins in TypeScript 2026-08-01 12:12:27 +02:00
sjg 42e5dc8604 docs: record frontend migration baseline 2026-08-01 12:09:38 +02:00
sjg e6f593b959 refactor: convert screenshot support to TypeScript 2026-08-01 12:08:44 +02:00
sjg 9ac6d7f82c refactor: convert WebGL renderer to TypeScript 2026-08-01 12:07:02 +02:00
sjg a58553ba66 refactor: convert decode history worker to TypeScript 2026-08-01 12:03:19 +02:00
sjg 7442437757 refactor: convert shared UI core to TypeScript 2026-08-01 12:01:07 +02:00
sjg bbc53d56b0 feat: generate typed frontend API contracts 2026-08-01 11:58:09 +02:00
sjg 888f793eb8 build: add frontend TypeScript toolchain 2026-08-01 11:52:47 +02:00
sjg 860760ecfc docs: add TypeScript migration plan 2026-08-01 11:48:20 +02:00
sjg b4ea35baf8 feat(ui): add dedicated audio controls section
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 0s
CI / reuse (pull_request) Failing after 0s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 0s
2026-08-01 11:32:34 +02:00
sjg 508ed8a8e7 feat(ui): separate scheduler controls subsection
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 0s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 1s
2026-08-01 11:28:51 +02:00
sjg d9fc623ef4 fix(ui): simplify operator layout picker
CI / lint (push) Failing after 1s
CI / test (push) Failing after 0s
CI / reuse (push) Failing after 0s
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
2026-08-01 11:25:32 +02:00
sjg 53736ef750 fix(ui): hide SDR power and align header controls
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 1s
2026-08-01 11:23:57 +02:00
sjg 6566eae2c7 fix(ui): keep workspace and rig context visible
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 0s
CI / test (push) Failing after 0s
CI / reuse (push) Failing after 1s
2026-08-01 11:19:31 +02:00
sjg f3eeddff7c fix(ui): close mobile overlays predictably 2026-08-01 11:18:37 +02:00
sjg 1540ca5b4e test(ui): cover shared component behavior 2026-08-01 11:17:17 +02:00
sjg e449704fd2 feat(ui): explain unavailable workspaces 2026-08-01 11:16:03 +02:00
sjg af74430551 fix(ui): make rig switching atomic and visible 2026-08-01 11:15:39 +02:00
sjg d07dbdc645 feat(ui): focus broadcast workspace on reception 2026-08-01 11:14:10 +02:00
sjg d07a627508 feat(ui): remember workspaces per rig 2026-08-01 11:13:21 +02:00
sjg 9fbf90ee91 feat(ui): explain operator workspace choices 2026-08-01 11:12:33 +02:00
sjg d393b3cefc feat(ui): drive workspaces from rig capabilities 2026-08-01 11:11:47 +02:00
sjg 35a492ec95 fix(ui): gate broadcast layout on rig capability
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 2s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 3s
CI / reuse (push) Failing after 1s
2026-08-01 11:05:45 +02:00
sjg b20da5c541 style(ui): polish shared interaction components
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 2s
2026-08-01 10:43:29 +02:00
sjg a0bdaa2c4b feat(ui): improve radio operator experience
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Failing after 1s
CI / reuse (pull_request) Failing after 1s
CI / lint (push) Failing after 1s
CI / test (push) Failing after 1s
CI / reuse (push) Failing after 0s
2026-08-01 02:24:47 +02:00
156 changed files with 37037 additions and 6904 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"
]
}
}
}
+51 -9
View File
@@ -2,10 +2,11 @@
# #
# SPDX-License-Identifier: GPL-2.0-or-later # SPDX-License-Identifier: GPL-2.0-or-later
# CI for the self-hosted, host-executor Podman runners (see container/). # CI for the Docker-executor runner (VM). The lint, test and frontend jobs run
# The runner image bakes in the Rust toolchain and all build dependencies, # inside the shared trx-rs SDK image (container/Containerfile), which bakes in
# so jobs go straight to cargo — no apt/rustup setup steps (which also # the pinned Rust toolchain, Node.js, Chromium and all build dependencies. The
# collided on the dpkg lock when jobs ran concurrently in the same runner). # reuse job uses the upstream Docker action, which the Docker executor launches
# as a sibling container.
name: CI name: CI
@@ -16,32 +17,73 @@ on:
env: env:
CARGO_TERM_COLOR: always 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: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: rustfmt - name: rustfmt
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: clippy - name: clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings run: cargo clippy --workspace --all-targets --all-features -- -D warnings
- name: sccache stats
if: always()
run: sccache --show-stats
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: git.haxx.space/sjg/trx-rs/sdk:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build - name: Build
run: cargo build --workspace --all-targets --locked run: cargo build --workspace --all-targets --locked
- name: Test - name: Test
run: cargo test --workspace --locked 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
steps:
- uses: actions/checkout@v4
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Install locked frontend dependencies
run: npm ci
- name: Type-check
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Test
run: npm test
# 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
reuse: reuse:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: REUSE compliance - uses: fsfe/reuse-action@v5
# `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
+3
View File
@@ -21,6 +21,9 @@ Cargo.lock
coverage/ coverage/
benchmarks/ benchmarks/
# Frontend dependencies
**/node_modules/
# Env # Env
.env .env
.env.* .env.*
Generated
+35
View File
@@ -2645,6 +2645,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -3137,6 +3146,7 @@ dependencies = [
"sgp4", "sgp4",
"tokio", "tokio",
"tracing", "tracing",
"ts-rs",
"uuid", "uuid",
] ]
@@ -3195,6 +3205,7 @@ dependencies = [
"trx-core", "trx-core",
"trx-frontend", "trx-frontend",
"trx-protocol", "trx-protocol",
"ts-rs",
"uuid", "uuid",
] ]
@@ -3238,6 +3249,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"trx-core", "trx-core",
"ts-rs",
] ]
[[package]] [[package]]
@@ -3333,6 +3345,29 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ts-rs"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
dependencies = [
"thiserror 2.0.18",
"ts-rs-macros",
"uuid",
]
[[package]]
name = "ts-rs-macros"
version = "12.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
dependencies = [
"proc-macro2",
"quote",
"syn",
"termcolor",
]
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.0"
+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 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 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 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).
+23 -1
View File
@@ -12,13 +12,19 @@ path = [
"trx-rs.toml.example", "trx-rs.toml.example",
"docs/**", "docs/**",
"aidocs/**", "aidocs/**",
"container/**",
".devcontainer/**",
"src/decoders/trx-ftx/README.md", "src/decoders/trx-ftx/README.md",
"src/decoders/trx-wxsat/README.md", "src/decoders/trx-wxsat/README.md",
"assets/trx-logo.png", "assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png", "src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png", "src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json", "src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json",
"src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js", "src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json",
"src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.worker.json",
] ]
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>" SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later" SPDX-License-Identifier = "GPL-2.0-or-later"
@@ -50,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"] 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-FileCopyrightText = "2021-2025 Ethan Halsall"
SPDX-License-Identifier = "MIT" 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"
+46 -39
View File
@@ -2,60 +2,67 @@
# #
# SPDX-License-Identifier: GPL-2.0-or-later # 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 # Single source of truth for the build environment. Used two ways:
# actions/checkout and actions/cache) and the `reuse` tool are baked in, so CI # * CI — as the job container for the lint/test jobs (Docker executor).
# runs skip the per-run apt/rustup install cost. `sudo` is present so the # * Dev — run locally or via .devcontainer for a reproducible toolchain.
# existing workflow's `sudo apt-get ...` / rustup steps remain valid — they #
# just become fast no-ops because everything is already installed. # 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 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 ARG NODE_MAJOR=20
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
RUSTUP_HOME=/opt/rustup \ RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/opt/cargo \ CARGO_HOME=/usr/local/cargo \
PATH=/opt/cargo/bin:/usr/local/bin:/usr/bin:/bin 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 \ 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 \ build-essential pkg-config cmake clang libclang-dev \
libopus-dev libasound2-dev libsoapysdr-dev \ libopus-dev libasound2-dev libsoapysdr-dev chromium \
&& rm -rf /var/lib/apt/lists/* && 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 - \ RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \ && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# REUSE >= 3 (Debian's packaged reuse is too old for REUSE.toml). # Pinned Rust toolchain, installed world-readable so any UID the runner or a
# The [charset-normalizer] extra provides an encoding-detection backend; # devcontainer uses can invoke cargo.
# 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.
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal \ | sh -s -- -y --no-modify-path \
--component rustfmt --component clippy \ --default-toolchain "${RUST_VERSION}" --profile minimal \
&& chmod -R a+rwX "$CARGO_HOME" "$RUSTUP_HOME" --component rustfmt --component clippy \
&& chmod -R a+rwX "$RUSTUP_HOME" "$CARGO_HOME"
# act_runner binary. # sccache — shared compilation cache. Enabled at build time via
RUN arch="$(dpkg --print-architecture)"; \ # RUSTC_WRAPPER (see the CI workflow and .devcontainer), not repo-wide, so
case "$arch" in amd64) rarch=amd64;; arm64) rarch=arm64;; *) echo "unsupported arch $arch" >&2; exit 1;; esac; \ # non-SDK builds are unaffected. musl build is static and runs anywhere.
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}" \ # The release asset is per-architecture, so resolve it from `uname -m` rather
&& chmod +x /usr/local/bin/act_runner # 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). WORKDIR /work
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"]
+156 -81
View File
@@ -3,116 +3,191 @@ SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later 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 A single container image that is the canonical build environment for trx-rs,
containers managed by systemd (Quadlet) — one per project — instead of two used **both** by CI and by developers. It bakes in the pinned Rust toolchain
VMs. Uses the **host executor**: workflow steps run directly inside a (matching `rust-toolchain.toml`) and every build dependency, so the compiler
purpose-built runner image that already has the Rust toolchain and all build and `clippy` are identical everywhere — no "works on my machine".
dependencies baked in, so CI runs skip the per-run install cost and no
Docker/Podman socket is needed.
## Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `Containerfile` | Runner image: Debian + build deps + clang + Rust + Node + `reuse` + `act_runner`. | | `Containerfile` | The SDK image (Debian + build deps + pinned Rust + Node + git). |
| `entrypoint.sh` | Registers on first boot (if needed), then runs the daemon. | | `runner-config.example.yaml` | Example act_runner config for the CI VM (Docker executor). |
| `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. |
## Prerequisites (once per host) ## Build and publish
Rootless Podman with cgroups v2 (default on modern distros). As the unprivileged Nothing in the image is architecture-specific: the base image, the Debian build
user that will own the runners: 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 ```bash
# Survive logout / start on boot without an interactive session. # from the repo root
loginctl enable-linger "$USER" 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. **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 —
## 1. Build the image 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 ```bash
cd container # on an x86_64 host
podman build -t trx-rs-ci:latest . 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 Tag with the Rust version too (e.g. `:1.97.1`) if you want reproducible pins.
the token. (Org- or instance-level tokens work too if you prefer wider scope.) 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 ```bash
mkdir -p ~/.config/containers/systemd docker pull git.haxx.space/sjg/trx-rs/sdk:latest
cp trx-rs-runner.container project2-runner.container ~/.config/containers/systemd/ docker run --rm git.haxx.space/sjg/trx-rs/sdk:latest sccache --version
# 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
``` ```
Once each runner shows **online** in the repo's runner list, blank out the ### macOS note
`GITEA_RUNNER_REGISTRATION_TOKEN` line again (the registration is persisted in
the `…-data` volume) and `systemctl --user daemon-reload`.
## 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 ## Developer use
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:
```yaml Reproducible one-off build, no local toolchain needed:
reuse:
runs-on: ubuntu-latest ```bash
steps: podman run --rm -it -v "$PWD":/work -w /work \
- uses: actions/checkout@v4 git.haxx.space/sjg/trx-rs/sdk:latest \
- name: REUSE compliance cargo build --release
run: reuse lint
``` ```
The `lint` and `test` jobs need no changes: their `sudo apt-get …` and rustup Or open the repo in the image via VS Code / JetBrains "Reopen in Container"
steps still run, but become fast no-ops because the image already has those (`.devcontainer/devcontainer.json` points at the same image).
packages and the toolchain. (`sudo` is included in the image for exactly this
reason.)
> If you would rather keep Docker-based actions and per-run images, use the Building outside the container? `rust-toolchain.toml` pins the same rustc, so
> **Docker executor** instead: drop the `:host` suffix from the label in `rustup` installs the matching toolchain automatically.
> `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.
## Tuning ## CI use
- **`capacity`** (in `config.yaml`) — concurrent jobs per runner. Rust builds `.gitea/workflows/ci.yml` runs the `lint`, `test` and `frontend` jobs *inside*
are heavy; 12 is sensible when two runners share a host. this image via the `container:` key, so they skip all setup and go straight to
- **`PodmanArgs=--cpus/--memory`** (in each `.container`) — hard resource caps `cargo` and `npm`. The frontend job needs three things from the image beyond
so one project cannot starve the other. Rust: Node.js for the toolchain, Chromium at `/usr/bin/chromium` for the
- **SELinux** — the `:Z` volume flag is already set; keep it if SELinux is browser smoke test, and `cargo``npm run verify-generated` regenerates the
enforcing. 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 ## Compilation cache (sccache)
`REUSE.toml` (the other files carry inline SPDX headers):
```toml The SDK image ships [`sccache`](https://github.com/mozilla/sccache). It is
[[annotations]] enabled via `RUSTC_WRAPPER=sccache` in CI and the devcontainer (not repo-wide,
path = ["container/**"] so plain `cargo` builds outside the SDK are unaffected).
SPDX-FileCopyrightText = "2026 Stan Grams <sjg@haxx.space>"
SPDX-License-Identifier = "GPL-2.0-or-later" - **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
+87
View File
@@ -0,0 +1,87 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Frontend architecture
The HTTP frontend is a strict TypeScript application built with esbuild. Rust
embeds deterministic JavaScript output from `assets/web/generated`; Cargo does
not run Node.js or contact the network.
## Runtime graph
`frontend/src/bootstrap.ts` is the only first-party script referenced by the
HTML document. Its imports establish startup order for WebGL support, shared UI
services, decoder dispatch, the local Leaflet AIS adapter, and the application
coordinator. Leaflet and the Opus decoder remain isolated vendored scripts.
Feature entries under `frontend/src/plugins` are ESM bundles. The typed plugin
loader imports them by feature group and keeps expensive map, scheduling, and
decoder behavior lazy. Shared code is emitted as content-hashed chunks. The
decode-history worker is an independent entry compiled against Web Worker
globals.
Dependencies point from application and feature code toward `core` and `api`.
`api/generated.ts` contains Rust wire formats; `api/client.ts` and focused
parsers validate untrusted HTTP, SSE, WebSocket, and worker data before it is
used as typed application state.
## Browser host contract
Separate lazy bundles cannot share module instances with the stable application
entry, so they use three intentional host namespaces:
| Global | Purpose | Mutation policy |
| --- | --- | --- |
| `window.trx` | Application state, core services, and feature registrations | The root is frozen; lazy features may register only their documented `modules.*` service. |
| `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
namespace.
The following transitional properties are explicitly part of the lazy-feature
host contract and are declared in `app.ts`: `lastSpectrumData`, `lastFreqHz`,
`currentBandwidthHz`, `ft8BaseHz`, `getDecodeHistoryRetentionMs`,
`applyDecodeHistoryRetention`, `getDecodeRigMeta`, `renderRdsOverlays`,
`buildAisVesselUrl`, `trxScheduleUiFrameJob`,
`takeSchedulerControlForDecoderDisable`, `navigateToTab`, `_syncRecorderState`,
and `refreshRdsUi`. Optional callbacks owned by lazy features are
`refreshCwTonePicker`, `updateFt8RfDisplay`, `clearSatPredictionDom`,
`syncWefaxToggle`, `updateAisBar`, `updateVdesBar`, `updateAprsBar`,
`updateFt8Bar`, `updateSatLiveState`, `applyCwAutoUi`, and
`applyCwAutoUiFromServer`.
This list is closed: new standalone mutable `window` properties are not an
accepted integration mechanism. Extend an existing typed service or introduce
an imported interface instead. Removing transitional properties as feature
boundaries become directly importable remains preferable.
## Build and verification
The generated directory is removed before every build, preventing orphaned
compatibility bundles. Stable feature entry names are allowlisted by the Rust
asset manifest; shared chunks use content hashes. The generic Rust handler
rejects unknown names and unsupported MIME types and serves embedded assets
with compression, ETags, and immutable caching.
CI installs from `package-lock.json`, caches only npm downloads, type-checks the
window and worker environments separately, lints, runs unit and DOM tests,
starts the application in Chromium, regenerates Rust contracts and bundles,
checks for drift, and runs REUSE validation after generation.
See `frontend/src/README.md` for local commands and
`docs/ts-migration-plan.md` for the migration decisions and completion gates.
+5 -1
View File
@@ -7,7 +7,11 @@ trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page
application served as embedded static assets (gzip-compressed with ETag application served as embedded static assets (gzip-compressed with ETag
caching) from the Actix-Web server. caching) from the Actix-Web server.
## Current asset inventory ## Historical asset inventory
This inventory records the frontend before the TypeScript migration. The
first-party JavaScript copies listed here have since been replaced by strict
TypeScript source and deterministic output under `assets/web/generated`.
| File | Lines | Size | | File | Lines | Size |
|------|------:|-----:| |------|------:|-----:|
+50
View File
@@ -0,0 +1,50 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# TypeScript migration baseline
This baseline records the browser architecture at the start of the migration.
It is intentionally historical; generated output sizes are tracked separately
by the deterministic frontend build.
## Startup order
The initial document loads these scripts in order:
1. `/vendor/opus-decoder-0.7.11.min.js`
2. `/vendor/leaflet.js`
3. `/leaflet-ais-tracksymbol.js`
4. `/webgl-renderer.js`
5. `/ui-core.js`
6. `/app.js`
Decoder and feature scripts are then loaded lazily by the inline loader in
`index.html`. The frontend smoke test locks the core ordering and rejects
remote script or stylesheet URLs.
## Source measurements
At baseline, first-party JavaScript comprised 23 files and approximately
776 KiB. The largest sources were:
| Source | Bytes |
|---|---:|
| `app.js` | 337,111 |
| `map-core.js` | 131,899 |
| `plugins/scheduler.js` | 60,883 |
| `plugins/bookmarks.js` | 30,543 |
| `plugins/sat.js` | 22,541 |
| `plugins/vchan.js` | 20,195 |
| `webgl-renderer.js` | 18,993 |
The migrated source snapshot contained 123 distinct direct `window.*`
assignments. These are compatibility callbacks, shared state, and plugin entry
points. New TypeScript code must not add to that set; the typed plugin registry
and explicit services replace it over the course of the migration.
All production scripts, stylesheets, fonts, icons, and map assets were already
served from local embedded routes. The automated startup test ensures no
remote runtime script or stylesheet dependency is introduced.
+586
View File
@@ -0,0 +1,586 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# TypeScript Migration Plan
> **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/`
>
> **Status**: Complete (2026-08-01)
## Implementation result
The migration was completed on `feat/typescript-frontend-migration`. The
baseline and phased sections below are retained as the decision record; their
descriptions of JavaScript files and classic loading refer to the pre-migration
state.
Completion evidence:
- all first-party browser sources are strict `.ts` files, checked by separate
DOM and Web Worker TypeScript projects with no JavaScript compatibility mode
or suppression directives;
- `bootstrap.ts` is the single first-party HTML entry and esbuild represents
startup order, lazy feature imports, shared hashed chunks, and the worker in
its module graph;
- obsolete source and generated compatibility JavaScript was removed;
- Rust generates rig, status, capability, decoder, and flattened frontend
metadata contracts into `api/generated.ts`; runtime guards validate HTTP,
SSE, WebSocket, and worker boundaries;
- the generic embedded-asset handler serves a build-generated allowlist with
constrained MIME types, compression, ETags, immutable caching, and no file
system lookup;
- intentional browser host namespaces and transitional lazy-feature properties
are documented in `docs/frontend-architecture.md`;
- CI uses locked npm dependencies, caches npm downloads rather than
`node_modules`, runs strict type checking and linting, unit/DOM/worker tests,
Chromium startup coverage, generated-output drift checks, and REUSE after
generation;
- Cargo continues to consume committed generated assets without invoking Node
or requiring network access.
The final local gate ran `npm ci`, type checking, linting, 30 frontend tests,
the Chromium smoke flow (startup, auth gate, audio controls, rig switching, map
initialization, and navigation), generated-contract and bundle verification,
workspace formatting, Clippy with warnings denied, all-target builds, workspace
tests, and REUSE 3.3 validation.
## 1. Decision
Migrating the web frontend to TypeScript is worthwhile, provided it is used to
remove implicit contracts and global coupling. Renaming JavaScript files to
`.ts` without changing their boundaries would add tooling without delivering
the main safety benefits.
The migration must be incremental. Every intermediate commit and pull request
must leave the frontend buildable and usable.
## 2. Baseline State
The frontend currently contains roughly 21,700 lines of first-party
JavaScript. Its largest components include:
- `app.js`: approximately 8,900 lines;
- `map-core.js`: approximately 3,500 lines;
- `plugins/scheduler.js`: approximately 1,500 lines;
- `plugins/bookmarks.js`: approximately 800 lines;
- `plugins/vchan.js`: approximately 560 lines.
Important characteristics of the current architecture are:
- scripts are embedded individually into the Rust binary with `include_str!`;
- the Rust HTTP server exposes an explicit route for each asset;
- plugins are loaded dynamically as classic scripts;
- script order is significant;
- modules communicate through `window.*`, `window.trx`, callbacks, and shared
mutable state;
- the main HTML file contains the plugin loader;
- a Web Worker is loaded through a fixed URL;
- Cargo builds do not require Node.js;
- CI images contain Node.js, but CI currently runs only Rust and REUSE checks;
- Leaflet, Opus decoder, fonts, images, and other vendored assets are local.
These constraints make a big-bang rewrite unnecessarily risky.
## 3. Goals
The migration should:
1. Create checked contracts between Rust responses and browser code.
2. Replace global callbacks with explicit module interfaces.
3. Split `app.js` by responsibility.
4. Make rig, capability, decoder, scheduler, audio, and spectrum state explicit.
5. Preserve lazy loading for expensive features.
6. Add frontend type checking, linting, and automated tests to CI.
7. Keep ordinary Cargo builds independent of Node.js and network access.
8. Preserve existing browser behavior throughout the migration.
## 4. Non-Goals
The migration will not initially:
- introduce a UI framework;
- redesign the user interface;
- convert vendored JavaScript to TypeScript;
- change REST, SSE, WebSocket, or worker protocols unless required to make an
existing contract unambiguous;
- make `build.rs` install packages or download frontend dependencies;
- convert all of `app.js` in one pull request.
## 5. Target Layout
```text
trx-frontend-http/
├── frontend/
│ ├── package.json
│ ├── package-lock.json
│ ├── tsconfig.json
│ ├── tsconfig.worker.json
│ ├── build.mjs
│ ├── src/
│ │ ├── bootstrap.ts
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── generated.ts
│ │ ├── core/
│ │ │ ├── dom.ts
│ │ │ ├── events.ts
│ │ │ ├── settings.ts
│ │ │ └── state.ts
│ │ ├── features/
│ │ │ ├── audio/
│ │ │ ├── bookmarks/
│ │ │ ├── map/
│ │ │ ├── navigation/
│ │ │ ├── radio/
│ │ │ ├── recorder/
│ │ │ ├── scheduler/
│ │ │ └── spectrum/
│ │ ├── decoders/
│ │ ├── workers/
│ │ └── legacy/
│ │ └── global-bridge.ts
│ └── tests/
└── assets/web/
├── generated/
├── vendor/
├── index.html
├── style.css
└── themes.css
```
The exact feature directories may evolve, but dependencies should point from
features toward `core` and `api`, never from `core` back into features.
## 6. Tooling
Use TypeScript for type checking and esbuild for bundling. A framework-specific
development server is not needed.
Recommended compiler baseline:
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"noEmit": true,
"lib": ["ES2022", "DOM"]
}
}
```
Worker code should use a separate configuration with `WebWorker` rather than
`DOM` globals.
The package scripts should provide at least:
```text
npm run typecheck
npm run lint
npm test
npm run build
npm run verify-generated
```
Pin dependencies in `package-lock.json`. New package and generated files must
carry or inherit valid REUSE licensing information.
## 7. Cargo and Asset Integration
Cargo must remain usable on machines without Node.js. Do not invoke `npm`
automatically from `build.rs`.
The initial integration should work as follows:
1. TypeScript and JavaScript sources live under `frontend/src`.
2. esbuild writes browser-ready output under `assets/web/generated`.
3. Generated browser output is committed to the repository.
4. Rust embeds generated output, not TypeScript source.
5. CI rebuilds the frontend and fails if committed output is stale.
During early phases, retain the existing public URLs, including:
```text
/app.js
/ui-core.js
/map-core.js
/scheduler.js
/decode-history-worker.js
```
Keeping these URLs stable avoids coupling the first migration phases to changes
in HTML loading, authentication rules, caching, or Rust routing.
After modules and code splitting are established, replace the explicit list of
Rust constants and handlers with an embedded generated-asset directory. The
server must still enforce an allowlist, correct MIME types, cache headers, and
path traversal protection.
## 8. Rust-to-TypeScript Contracts
Generate TypeScript definitions from Rust wire types instead of maintaining
parallel handwritten representations.
Initial candidates include:
- `RigState`, `RigInfo`, and `RigCapabilities`;
- the `/rigs` response;
- decoder registry entries;
- scheduler configuration and status;
- filter and spectrum state;
- recorder responses;
- SSE update payloads;
- decoded-message variants;
- WebSocket control and status messages.
A generator such as `ts-rs` can produce
`frontend/src/api/generated.ts`. Generated definitions should describe only
wire formats. UI state and view models should remain handwritten.
CI must regenerate these definitions and fail when the working tree changes.
Serde renames, tagged enums, optional fields, flattened values, and numeric
ranges must be checked explicitly during the initial generator integration.
TypeScript types do not validate data at runtime. Critical compatibility
boundaries should retain focused runtime checks for missing or malformed data,
particularly when clients and servers may run different versions.
## 9. Target Module Contracts
Plugins should eventually implement an explicit interface rather than assigning
callbacks to `window`:
```ts
interface TrxPlugin {
readonly id: string;
initialize(context: PluginContext): void | Promise<void>;
dispose?(): void;
}
interface PluginContext {
api: TrxApi;
events: TrxEventBus;
navigation: NavigationService;
notifications: NotificationService;
state: ReadonlyRadioState;
}
```
During the transition, `legacy/global-bridge.ts` may expose the minimum globals
required by unmigrated scripts. The bridge must shrink as migration progresses;
new feature code must not add new global callbacks.
State should be divided by responsibility rather than replaced by one large
typed global:
```ts
interface RadioState {
activeRigId: string | null;
capabilities: RigCapabilities | null;
connection: ConnectionState;
frequencyHz: number | null;
mode: RigMode | null;
bandwidthHz: number | null;
}
interface AudioState {
rx: AudioStreamState;
tx: AudioStreamState;
volume: VolumeState;
}
interface DecoderState {
registry: DecoderDescriptor[];
status: ReadonlyMap<DecoderId, DecoderStatus>;
}
```
Commands should receive a rig ID explicitly. They must not infer their target
from mutable global selection state after an asynchronous operation begins.
## 10. Migration Phases
### Phase 0: Baseline and Measurements
- Record current asset names, sizes, and load order.
- Add a browser startup smoke test.
- Record existing global symbols and plugin callbacks.
- Confirm that generated bundles do not introduce remote runtime dependencies.
**Exit criterion:** current behavior and asset loading have an automated
baseline.
### Phase 1: Tooling Scaffold
- Add the frontend package, lockfile, TypeScript configuration, and esbuild.
- Allow existing JavaScript as build input without type checking it globally.
- Produce fixed-name outputs matching current URLs.
- Add frontend commands and CI checks.
- Document local frontend development commands.
**Exit criterion:** existing JavaScript passes through the frontend build with
no runtime changes, and CI detects stale generated output.
### Phase 2: Generated API Types
- Add Rust-to-TypeScript type generation.
- Generate the first status, rig, capability, and decoder contracts.
- Introduce a typed fetch/post client and typed SSE decoding boundary.
- Keep narrow runtime guards at compatibility boundaries.
**Exit criterion:** new API consumers cannot use untyped response objects.
### Phase 3: Core Browser Services
Convert or create:
- DOM lookup helpers;
- notifications and confirmations;
- settings and per-rig preferences;
- navigation;
- the event bus;
- application state stores;
- plugin registry and loader.
Convert `ui-core.js` as the first strict TypeScript entry.
**Exit criterion:** shared UI behavior is TypeScript, tested, and does not add
new globals.
### Phase 4: Independent Leaf Modules
Convert lower-coupling code first:
1. WebGL renderer;
2. decode-history worker;
3. screenshot support;
4. FT2 and FT4;
5. WSPR;
6. CW and other decoder views.
Workers must be separate build entries.
**Exit criterion:** each converted module has typed inputs, outputs, and tests.
### Phase 5: Plugin Loading
- Move the inline loader out of `index.html`.
- Replace classic-script injection with typed dynamic imports.
- Register plugins through `TrxPlugin`.
- Preserve lazy loading by tab and feature.
- Remove the corresponding global callbacks after each plugin migrates.
**Exit criterion:** plugin dependencies and loading order are represented by the
module graph rather than implicit script order.
### Phase 6: Split the Main Application
Extract `app.js` by responsibility:
1. authentication and API transport;
2. rig enumeration and switching;
3. radio commands and capabilities;
4. audio streaming;
5. spectrum state and rendering;
6. decode history;
7. recorder;
8. keyboard shortcuts;
9. application bootstrap.
Do not split by arbitrary line ranges. Each extraction must establish an
explicit interface and remove the corresponding globals.
**Exit criterion:** the bootstrap file composes services and features but does
not contain their implementations.
### Phase 7: High-Coupling Features
Convert the remaining large features after the core contracts are stable:
- scheduler and satellite scheduler;
- bookmarks;
- virtual channels;
- map and map-backed statistics;
- remaining decoder plugins.
**Exit criterion:** no first-party classic scripts or untyped plugin callbacks
remain.
### Phase 8: Asset-Server Consolidation
- Enable code splitting and hashed chunks.
- Embed the generated asset directory in Rust.
- Serve generated assets through a generic, allowlisted handler.
- Add appropriate immutable caching for hashed output.
- Retain stable handling for HTML and version metadata.
- Remove obsolete fixed-asset constants and handlers.
**Exit criterion:** Rust no longer needs a source edit for every generated
frontend chunk.
### Phase 9: Strictness and Cleanup
- Remove `allowJs`.
- Remove the legacy global bridge.
- Enable all selected strict compiler and lint rules.
- Remove obsolete generated compatibility bundles.
- Update architecture and contributor documentation.
**Exit criterion:** all first-party frontend source is strict TypeScript and the
browser runtime exposes only intentionally documented globals.
## 11. Testing Strategy
Frontend CI should run:
```sh
npm ci
npm run typecheck
npm run lint
npm test
npm run build
git diff --exit-code -- assets/web/generated frontend/src/api/generated.ts
```
Testing should cover four layers:
1. **Pure unit tests** for bandwidth calculations, formatting, state changes,
capability decisions, and message routing.
2. **DOM component tests** for dialogs, tabs, workspace selection, rig
switching, and collapsible control sections.
3. **Worker tests** for decode-history pruning, batching, and message formats.
4. **Browser smoke tests** for startup, authentication gating, plugin loading,
navigation, rig switching, audio controls, and map initialization.
The existing dependency-free `ui-core` test can remain during the scaffold
phase. It should be moved to the standard TypeScript test runner once that
runner is established.
## 12. CI Changes
Add a dedicated frontend job rather than hiding frontend work inside the Cargo
jobs. The job should:
- use the Node.js version provided by the runner image;
- run with `npm ci`, never a mutable install;
- cache the npm download cache, not `node_modules`;
- type-check, lint, test, and build;
- verify generated artifacts and Rust-generated types are current;
- run REUSE validation after generated files are produced.
Rust lint and test jobs should continue to consume committed generated assets.
## 13. Pull Request Strategy
Use small, independently reversible pull requests. A recommended sequence is:
1. tooling and unchanged JavaScript build;
2. generated Rust wire types and typed API client;
3. `ui-core` conversion;
4. worker and WebGL conversion;
5. decoder plugin conversions in small groups;
6. plugin registry and dynamic imports;
7. one `app.js` responsibility per PR;
8. scheduler, bookmarks, and map conversions;
9. generic embedded asset serving;
10. removal of JavaScript compatibility mode.
Every PR should include:
- runtime behavior preserved or intentionally documented;
- frontend type checking and tests;
- strict Rust formatting and Clippy;
- generated-artifact drift verification;
- no newly introduced undocumented `window` globals;
- no remote runtime asset dependency.
## 14. Risks and Mitigations
### Superficial Conversion
**Risk:** globals receive declarations but remain coupled and mutable.
**Mitigation:** require every converted module to expose explicit inputs and
outputs and remove at least the globals it replaces.
### Cargo Becomes Dependent on Node
**Risk:** builds fail on systems without Node or network access.
**Mitigation:** commit deterministic generated output and keep npm outside
`build.rs`.
### Script-Order Regressions
**Risk:** switching to modules changes execution timing and scope.
**Mitigation:** retain current public entries initially, then replace script
loading only after the plugin registry is in place.
### Unreviewable `app.js` Rewrite
**Risk:** a large conversion is difficult to review, test, or bisect.
**Mitigation:** extract one responsibility at a time and keep bootstrap working
after every extraction.
### Rust and Browser Contracts Drift
**Risk:** TypeScript compiles against stale wire definitions.
**Mitigation:** generate types from Rust and enforce a clean working tree after
generation in CI.
### Generated Asset Noise
**Risk:** committed bundles make reviews noisy.
**Mitigation:** separate source and generated commits when useful, include
source maps, and require deterministic output.
### Bundle or Startup Regression
**Risk:** bundling loads too much code eagerly.
**Mitigation:** record the current baseline, preserve feature-level lazy loads,
and track entry/chunk sizes in CI.
## 15. Initial Proof of Concept
The first implementation PR should be deliberately limited to:
1. adding the frontend package and locked toolchain;
2. building existing JavaScript to fixed-name generated output;
3. adding frontend CI and drift checks;
4. generating initial rig/status TypeScript definitions;
5. introducing the typed API client;
6. converting `ui-core.js` to strict TypeScript;
7. preserving every existing asset URL and observable behavior.
This proof of concept is the decision gate for the remainder of the migration.
If it materially improves contract safety without making Cargo development
unwieldy, continue with the phased plan. If it does not, the repository can
retain the tooling and the converted core without committing to a full rewrite.
## 16. Completion Criteria
The migration is complete when:
- all first-party browser source is strict TypeScript;
- vendor files remain isolated and declared through narrow type adapters;
- Rust wire types generate their browser contracts;
- no feature depends on accidental script ordering;
- no undocumented mutable `window` callbacks remain;
- `app.js` has been replaced by a small typed bootstrap and feature modules;
- frontend type checking, linting, unit tests, component tests, browser smoke
tests, and generated-output checks run in CI;
- Cargo builds remain possible without Node.js or network access;
- production assets remain local, embedded, cacheable, and reproducible.
+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"]
@@ -29,3 +29,4 @@ hex = "0.4"
pickledb = "0.5" pickledb = "0.5"
dirs = "6" dirs = "6"
uuid = { workspace = true } uuid = { workspace = true }
ts-rs = "12.0.1"
@@ -0,0 +1,360 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/ais.ts
var aisWindow = window;
var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
var aisStatus = document.getElementById("ais-status");
var aisMessagesEl = document.getElementById("ais-messages");
var aisFilterInput = document.getElementById("ais-filter");
var aisBarOverlay = document.getElementById("ais-bar-overlay");
var aisChannelSummaryEl = document.getElementById("ais-channel-summary");
var aisVesselCountEl = document.getElementById("ais-vessel-count");
var aisLatestSeenEl = document.getElementById("ais-latest-seen");
var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3;
var AIS_DEFAULT_A_HZ = 161975e3;
var AIS_CHANNEL_SPACING_HZ = 5e4;
var aisFilterText = "";
var aisMessageHistory = [];
function currentAisHistoryRetentionMs() {
return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
}
function pruneAisMessageHistory() {
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
}
function scheduleAisUi(key, job) {
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
aisWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAisHistoryRender() {
scheduleAisUi("ais-history", () => {
renderAisHistory();
});
}
function scheduleAisBarUpdate() {
scheduleAisUi("ais-bar", () => {
updateAisBar();
});
}
function formatAisMhz(freqHz) {
return `${(freqHz / 1e6).toFixed(3)} MHz`;
}
function currentAisChannelPlan() {
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
return {
aHz: safeAHz,
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ
};
}
function aisChannelInfo(channel) {
const plan = currentAisChannelPlan();
const ch = (channel ?? "").trim().toUpperCase();
if (ch === "B") {
return {
label: "AIS-B",
badgeClass: "ais-badge ais-badge-channel-b",
freqText: formatAisMhz(plan.bHz)
};
}
return {
label: "AIS-A",
badgeClass: "ais-badge ais-badge-channel-a",
freqText: formatAisMhz(plan.aHz)
};
}
function aisDisplayName(msg) {
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
}
function aisDisplayNameHtml(msg) {
const label = escapeAisHtml(aisDisplayName(msg));
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
if (!url) return label;
return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
}
function aisTypeLabel(type) {
switch (Number(type)) {
case 1:
case 2:
case 3:
return "Class A Position";
case 4:
return "Base Station";
case 5:
return "Static/Voyage";
case 18:
return "Class B Position";
case 19:
return "Class B Extended";
case 21:
return "Aid to Nav";
case 24:
return "Class B Static";
default:
return `Type ${type ?? "--"}`;
}
}
function aisAgeText(tsMs) {
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function aisMotionText(msg) {
const parts = [
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null
].filter(Boolean);
return parts.join(" · ");
}
function aisRouteText(msg) {
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
}
function aisDistanceText(msg) {
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
return "";
}
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`;
}
function aisLatestByVessel(messages) {
const byMmsi = /* @__PURE__ */ new Map();
for (const msg of messages) {
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
if (!byMmsi.has(key)) byMmsi.set(key, msg);
}
return Array.from(byMmsi.values());
}
function updateAisSummary() {
const plan = currentAisChannelPlan();
if (aisChannelSummaryEl) {
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
}
const vessels = aisLatestByVessel(aisMessageHistory);
if (aisVesselCountEl) {
const count = vessels.length;
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
}
if (aisLatestSeenEl) {
const latest = aisMessageHistory[0];
if (!latest) {
aisLatestSeenEl.textContent = "No traffic yet";
} else {
const channel = aisChannelInfo(latest.channel);
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
}
}
}
function aisSummaryText(msg) {
const parts = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg) {
const row = document.createElement("details");
row.className = "ais-message";
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
const name = aisDisplayName(msg);
const nameHtml = aisDisplayNameHtml(msg);
const channel = aisChannelInfo(msg.channel);
const motion = aisMotionText(msg);
const route = aisRouteText(msg);
const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [
name,
msg.mmsi,
msg.channel,
channel.label,
msg.vessel_name,
msg.callsign,
msg.destination,
aisTypeLabel(msg.message_type)
].filter(Boolean).join(" ").toUpperCase();
row.innerHTML = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
row.querySelectorAll("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row);
return row;
}
function applyAisFilterToRow(row) {
if (!aisFilterText) {
row.style.display = "";
return;
}
const message = row.dataset.filterText || "";
row.style.display = message.includes(aisFilterText) ? "" : "none";
}
function updateAisBar() {
if (!aisBarOverlay) return;
updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none";
aisBarOverlay.innerHTML = "";
return;
}
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
for (const msg of messages) {
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
const pin = msg.lat != null && msg.lon != null ? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>` : "";
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
const channel = aisChannelInfo(msg.channel);
const distance = aisDistanceText(msg);
const details = [
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
escapeAisHtml(channel.label),
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
distance ? escapeAisHtml(distance) : null,
escapeAisHtml(aisAgeText(msg._tsMs))
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
}
aisBarOverlay.innerHTML = html;
aisBarOverlay.style.display = "flex";
}
aisWindow.updateAisBar = updateAisBar;
aisWindow.clearAisBar = function() {
resetAisHistoryView();
};
function resetAisHistoryView() {
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = [];
updateAisBar();
renderAisHistory();
aisWindow.clearMapMarkersByType?.("ais");
}
function renderAisHistory() {
pruneAisMessageHistory();
if (!aisMessagesEl) {
updateAisSummary();
return;
}
const fragment = document.createDocumentFragment();
for (const message of aisMessageHistory) {
fragment.appendChild(renderAisRow(message));
}
aisMessagesEl.replaceChildren(fragment);
updateAisSummary();
}
function addAisMessage(msg) {
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
aisMessageHistory.unshift(msg);
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
plotAisMessage(msg);
}
function plotAisMessage(msg) {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
}
function normalizeServerAisMessage(msg) {
return {
...msg,
rig_id: msg.rig_id || null
};
}
function onServerAisBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerAisMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
plotAisMessage(next);
normalized.push(next);
}
normalized.reverse();
aisMessageHistory = normalized.concat(aisMessageHistory);
pruneAisMessageHistory();
scheduleAisBarUpdate();
scheduleAisHistoryRender();
}
function pruneAisHistoryView() {
pruneAisMessageHistory();
updateAisBar();
renderAisHistory();
}
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 hostCore.postPath("/clear_ais_decode");
resetAisHistoryView();
} catch (e) {
console.error("AIS history clear failed", e);
}
})();
});
if (aisFilterInput) {
aisFilterInput.addEventListener("input", () => {
aisFilterText = aisFilterInput.value.trim().toUpperCase();
renderAisHistory();
});
}
function onServerAis(msg) {
if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg));
}
updateAisSummary();
window.trxPluginRuntime.registerDecoder({
id: "ais",
onMessage: onServerAis,
onBatch: onServerAisBatch,
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
import {
aprsAgeText,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/aprs.ts
var aprsWindow = window;
var escapeAprsHtml = (input) => hostCore.escapeMapHtml(input);
var showAprsHint = (message, durationMs) => {
hostCore.showHint(message, durationMs);
};
var aprsStatus = document.getElementById("aprs-status");
var aprsPacketsEl = document.getElementById("aprs-packets");
var aprsFilterInput = document.getElementById("aprs-filter");
var aprsBarOverlay = document.getElementById("aprs-bar-overlay");
var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
var aprsTotalCountEl = document.getElementById("aprs-total-count");
var aprsVisibleCountEl = document.getElementById("aprs-visible-count");
var aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
var aprsFilterText = "";
var aprsPacketHistory = [];
var aprsBarDismissedAtMs = 0;
var aprsOnlyPos = false;
var aprsHideCrc = false;
var aprsCollapseDup = false;
var aprsTypeFilter = "all";
function currentAprsHistoryRetentionMs() {
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
}
function pruneAprsPacketHistory() {
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
}
function scheduleAprsUi(key, job) {
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
aprsWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAprsHistoryRender() {
scheduleAprsUi("aprs-history", () => {
renderAprsHistory();
});
}
function scheduleAprsBarUpdate() {
scheduleAprsUi("aprs-bar", () => {
updateAprsBar();
});
}
function aprsDistanceText(pkt) {
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`;
}
function aprsFilterMatch(pkt) {
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (aprsHideCrc && !pkt.crcOk) return false;
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
if (!aprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
aprsPacketCategory(pkt)
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
}
}
}
function updateAprsChipState() {
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
});
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
async function copyAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
} catch {
showAprsHint("Copy failed", 1500);
}
}
function renderAprsRow(pkt, isFresh) {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => {
aprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyAprsCoords(text);
}
});
}
function renderAprsHistory() {
pruneAprsPacketHistory();
if (!aprsPacketsEl) {
updateAprsSummary();
updateAprsChipState();
return;
}
const visible = aprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (const [index, packet] of visible.entries()) {
fragment.appendChild(renderAprsRow(packet, index === 0));
}
aprsPacketsEl.replaceChildren(fragment);
updateAprsSummary();
updateAprsChipState();
}
function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
return;
}
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>`;
for (const pkt of frames) {
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
const dest = escapeAprsHtml(pkt.destCall || "");
const info = escapeAprsHtml(pkt.info || "");
const pin = pkt.lat != null && pkt.lon != null ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` : "";
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div></div>`;
}
aprsBarOverlay.innerHTML = html;
aprsBarOverlay.style.display = "flex";
}
aprsWindow.updateAprsBar = updateAprsBar;
aprsWindow.clearAprsBar = function() {
resetAprsHistoryView();
};
aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
}
};
function resetAprsHistoryView() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs");
}
function pruneAprsHistoryView() {
pruneAprsPacketHistory();
updateAprsBar();
renderAprsHistory();
}
function plotAprsPacket(pkt) {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
plotAprsPacket(pkt);
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
function normalizeServerAprsPacket(pkt) {
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
}
function onServerAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotAprsPacket(next);
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
normalized.reverse();
aprsPacketHistory = normalized.concat(aprsPacketHistory);
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
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 hostCore.postPath("/clear_aprs_decode");
resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
}
})();
});
if (aprsOnlyPosBtn) {
aprsOnlyPosBtn.addEventListener("click", () => {
aprsOnlyPos = !aprsOnlyPos;
renderAprsHistory();
});
}
if (aprsHideCrcBtn) {
aprsHideCrcBtn.addEventListener("click", () => {
aprsHideCrc = !aprsHideCrc;
renderAprsHistory();
});
}
if (aprsCollapseDupBtn) {
aprsCollapseDupBtn.addEventListener("click", () => {
aprsCollapseDup = !aprsCollapseDup;
renderAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
aprsTypeFilter = type;
renderAprsHistory();
});
});
if (aprsFilterInput) {
aprsFilterInput.addEventListener("input", () => {
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
renderAprsHistory();
});
}
function onServerAprs(pkt) {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
}
renderAprsHistory();
window.trxPluginRuntime.registerDecoder({
id: "aprs",
onMessage: onServerAprs,
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
}
});
@@ -0,0 +1,370 @@
import {
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/background-decode.ts
var bgdWindow = window;
(function() {
"use strict";
function bgdSupportedIds() {
return (bgdWindow.decoderRegistry || []).filter(function(d) {
return d.background_decode;
}).map(function(d) {
return d.id;
});
}
let backgroundDecodeRole = null;
let currentRigId = null;
let currentConfig = null;
let bookmarkList = [];
let statusInterval = null;
let bgdDirty = false;
function initBackgroundDecode(rigId, role) {
backgroundDecodeRole = role;
currentRigId = rigId || null;
if (currentRigId) loadBackgroundDecode();
startStatusPolling();
}
function setBackgroundDecodeRig(rigId) {
const nextRigId = rigId || null;
if (nextRigId === currentRigId) return;
currentRigId = nextRigId;
if (!currentRigId) return;
loadBackgroundDecode();
}
function apiGetConfig(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) {
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json();
});
}
function apiPutConfig(rigId, config) {
return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config)
}).then(function(r) {
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json();
});
}
function apiResetConfig(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "DELETE"
}).then(function(r) {
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json();
});
}
function apiGetStatus(rigId) {
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function(r) {
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json();
});
}
function apiGetBookmarks() {
return fetch("/bookmarks").then(function(r) {
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json();
});
}
function loadBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) {
currentConfig = config;
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
renderBackgroundDecode();
clearBgdDirty();
pollBackgroundDecodeStatus();
}).catch(function(err) {
console.error("background decode load failed", err);
});
}
function supportedBookmarks() {
return bookmarkList.filter(function(bookmark) {
return bookmarkDecoderKinds(bookmark).length > 0;
});
}
function bookmarkDecoderKinds(bookmark) {
const ids = bgdSupportedIds();
const decoders = bookmark.decoders ?? [];
const explicit = decoders.map(function(item) {
return item.trim().toLowerCase();
}).filter(function(item, index, arr) {
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
});
if (explicit.length > 0) return explicit;
const mode = bookmark.mode.trim().toUpperCase();
return (bgdWindow.decoderRegistry || []).filter(function(d) {
return d.activation === "mode_bound" && d.background_decode && d.active_modes.indexOf(mode) >= 0;
}).map(function(d) {
return d.id;
});
}
function renderBackgroundDecode() {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist();
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) {
el.disabled = !isControl;
});
}
const saveBtn = document.getElementById("background-decode-save-btn");
const resetBtn = document.getElementById("background-decode-reset-btn");
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
}
function renderBookmarkChecklist(filterText = "") {
const container = document.getElementById("bgd-bookmark-checklist");
if (!container) return;
container.innerHTML = "";
const selectedIds = new Set(
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
);
const all = supportedBookmarks();
const filter = (filterText || "").trim().toLowerCase();
const filtered = filter ? all.filter(function(bm) {
const text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
return text.indexOf(filter) >= 0;
}) : all;
if (filtered.length === 0) {
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
return;
}
filtered.forEach(function(bookmark) {
const row = document.createElement("label");
row.className = "bgd-checklist-row";
const decoders = bookmarkDecoderKinds(bookmark);
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
row.querySelector("input")?.addEventListener("change", function(e) {
onChecklistToggle(bookmark.id, e.currentTarget.checked);
});
container.appendChild(row);
});
}
function onChecklistToggle(bookmarkId, checked) {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
currentConfig.bookmark_ids.push(bookmarkId);
} else if (!checked) {
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function(id) {
return id !== bookmarkId;
});
}
markBgdDirty();
}
function saveBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
const payload = {
remote: rigId,
enabled: document.getElementById("background-decode-enabled")?.checked ?? false,
bookmark_ids: currentConfig?.bookmark_ids.slice() ?? []
};
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.disabled = true;
apiPutConfig(rigId, payload).then(function(saved) {
currentConfig = saved;
renderBackgroundDecode();
clearBgdDirty();
pollBackgroundDecodeStatus();
showToast("Background decode saved.", false);
}).catch(function(err) {
showToast(`Save failed: ${errorMessage(err)}`, true);
}).finally(function() {
if (btn) btn.disabled = false;
});
}
async function resetBackgroundDecode() {
const rigId = currentRigId;
if (!rigId) return;
if (!await bgdWindow.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
apiResetConfig(rigId).then(function(saved) {
currentConfig = saved;
renderBackgroundDecode();
clearBgdDirty();
pollBackgroundDecodeStatus();
showToast("Background decode reset.", false);
}).catch(function(err) {
showToast(`Reset failed: ${errorMessage(err)}`, true);
});
}
function startStatusPolling() {
if (statusInterval) clearInterval(statusInterval);
statusInterval = setInterval(pollBackgroundDecodeStatus, 15e3);
}
function pollBackgroundDecodeStatus() {
const rigId = currentRigId;
if (!rigId) return;
apiGetStatus(rigId).then(renderStatus).catch(function() {
});
}
function renderStatus(status) {
const card = document.getElementById("background-decode-status-card");
if (!card) return;
const entries = status.entries ?? [];
if (!entries.length) {
card.textContent = "No background decode bookmarks configured.";
return;
}
const summary = [];
if (status.active_rig) {
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else {
summary.push("This rig is not currently selected for audio.");
}
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
html += '<div class="bgd-status-list">';
entries.forEach(function(entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = [];
if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase());
}
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
});
html += "</div>";
card.innerHTML = html;
}
function prettyState(state) {
switch (state) {
case "active":
return "✓ Active";
case "out_of_span":
return "△ Out of span";
case "waiting_for_spectrum":
return "△ Waiting";
case "waiting_for_user":
return "△ No user";
case "missing_bookmark":
return "✗ Missing";
case "no_supported_decoders":
return "✗ Unsupported";
case "disabled":
return "△ Disabled";
case "handled_by_scheduler":
return "△ Scheduler";
case "scheduler_has_control":
return "△ Scheduler";
case "handled_by_virtual_channel":
return "△ VChan";
default:
return "△ Inactive";
}
}
function setCheckbox(id, value) {
const el = document.getElementById(id);
if (el) el.checked = value;
}
function formatFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
return `${String(hz)} Hz`;
}
function escHtml(value) {
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
function markBgdDirty() {
if (bgdDirty) return;
bgdDirty = true;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty");
}
function clearBgdDirty() {
bgdDirty = false;
const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.remove("sch-dirty");
}
function showToast(msg, isError) {
const el = document.getElementById("background-decode-toast");
if (!el) return;
el.textContent = msg;
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
el.style.display = "block";
setTimeout(function() {
el.style.display = "none";
}, 3e3);
}
function selectAllBookmarks() {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
const ids = supportedBookmarks().map(function(bm) {
return bm.id;
});
currentConfig.bookmark_ids = ids;
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
markBgdDirty();
}
function deselectAllBookmarks() {
if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
}
currentConfig.bookmark_ids = [];
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
markBgdDirty();
}
function wireBackgroundDecodeEvents() {
const filterInput = document.getElementById("bgd-bookmark-filter");
if (filterInput && !filterInput._wired) {
filterInput._wired = true;
filterInput.addEventListener("input", function() {
renderBookmarkChecklist(filterInput.value);
});
}
const enabledCb = document.getElementById("background-decode-enabled");
if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true;
enabledCb.addEventListener("change", function() {
markBgdDirty();
});
}
const selectAllBtn = document.getElementById("bgd-select-all-btn");
if (selectAllBtn && !selectAllBtn._wired) {
selectAllBtn._wired = true;
selectAllBtn.addEventListener("click", selectAllBookmarks);
}
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
if (deselectAllBtn && !deselectAllBtn._wired) {
deselectAllBtn._wired = true;
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
}
const saveBtn = document.getElementById("background-decode-save-btn");
if (saveBtn && !saveBtn._wired) {
saveBtn._wired = true;
saveBtn.addEventListener("click", saveBackgroundDecode);
}
const resetBtn = document.getElementById("background-decode-reset-btn");
if (resetBtn && !resetBtn._wired) {
resetBtn._wired = true;
resetBtn.addEventListener("click", () => {
void resetBackgroundDecode();
});
}
}
bgdWindow.trx ??= {};
bgdWindow.trx.modules ??= {};
bgdWindow.trx.modules.backgroundDecode = {
initialize: initBackgroundDecode,
wireEvents: wireBackgroundDecodeEvents,
setRig: setBackgroundDecodeRig
};
})();
@@ -0,0 +1,667 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/bookmarks.ts
var bridge = window;
function bmEl(id) {
const element = document.getElementById(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);
}
var bmScope = "general";
function bmScopeParam(prefix, scope) {
const sep = prefix ? "&" : "?";
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
}
var bmList = [];
var bmOverlayList = [];
var bmOverlayRevision = 0;
var bmFilteredList = [];
var bmEditScope = null;
var bmCurrentPage = 1;
var BM_PAGE_SIZE = 25;
var bmSelected = /* @__PURE__ */ new Set();
function bmFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "GHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "kHz";
return `${hz}Hz`;
}
function bmEsc(str) {
const d = document.createElement("div");
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
function bmCanControl() {
return !hostState.authEnabled || hostState.authRole === "control";
}
function bmSyncAccess() {
const canCtrl = bmCanControl();
const addBtn = bmEl("bm-add-btn");
const selectAllBtn = bmEl("bm-select-all-btn");
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
}
function bmListScope() {
return hostState.lastActiveRigId || "general";
}
async function bmFetchOverlay() {
const overlayScope = bmListScope();
try {
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
bmOverlayList = await resp.json();
} catch (e) {
console.error("Failed to fetch overlay bookmarks:", e);
bmOverlayList = [];
}
bmOverlayRevision++;
if (typeof bridge.syncBookmarkMapLocators === "function") {
bridge.syncBookmarkMapLocators(bmOverlayList);
}
hostCore.scheduleSpectrumDraw();
}
async function bmFetch(categoryFilter) {
let url = "/bookmarks";
let hasQuery = false;
if (categoryFilter && categoryFilter !== "") {
url += "?category=" + encodeURIComponent(categoryFilter);
hasQuery = true;
}
url += bmScopeParam(hasQuery);
const overlayPromise = bmFetchOverlay();
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
bmList = await resp.json();
} catch (e) {
console.error("Failed to fetch bookmarks:", e);
bmList = [];
}
bmSelected.clear();
bmUpdateSelectionUi();
bmSyncAccess();
bmApplyFilters();
void bmRefreshCategoryFilter(categoryFilter);
await overlayPromise;
}
function bmApplyFilters() {
const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
let filtered = modeFilter ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter) : bmList;
filtered = text ? filtered.filter(
(bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text)
) : filtered;
bmFilteredList = filtered;
bmCurrentPage = 1;
bmRender(filtered);
}
async function bmRefreshCategoryFilter(keepValue) {
const sel = bmEl("bm-category-filter");
const modeSel = bmEl("bm-mode-filter");
if (!sel && !modeSel) return;
try {
const resp = await fetch("/bookmarks" + bmScopeParam(false));
if (!resp.ok) return;
const all = await resp.json();
if (sel) {
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
while (sel.options.length > 1) sel.remove(1);
cats.forEach((cat) => {
const opt = document.createElement("option");
opt.value = cat;
opt.textContent = cat;
sel.add(opt);
});
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
}
if (modeSel) {
const keepMode = modeSel.value;
const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
while (modeSel.options.length > 1) modeSel.remove(1);
modes.forEach((mode) => {
const opt = document.createElement("option");
opt.value = mode;
opt.textContent = mode;
modeSel.add(opt);
});
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
}
} catch {
}
}
function bmRender(list) {
const tbody = bmEl("bm-tbody");
const emptyEl = bmEl("bm-empty");
const paginatorEl = bmEl("bm-paginator");
const pageSummaryEl = bmEl("bm-page-summary");
const pageIndicatorEl = bmEl("bm-page-indicator");
const prevBtn = bmEl("bm-page-prev");
const nextBtn = bmEl("bm-page-next");
if (!tbody) return;
tbody.innerHTML = "";
if (list.length === 0) {
if (emptyEl) emptyEl.style.display = "";
if (paginatorEl) paginatorEl.style.display = "none";
return;
}
if (emptyEl) emptyEl.style.display = "none";
const canControl = bmCanControl();
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
bmCurrentPage = page;
const startIndex = (page - 1) * BM_PAGE_SIZE;
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
const pageItems = list.slice(startIndex, endIndex);
const showScope = bmScope !== "general";
pageItems.forEach((bm) => {
const tr = document.createElement("tr");
tr.dataset.bmId = bm.id;
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
const locatorCell = bm.locator || "--";
const catCell = bm.category || "Uncategorised";
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
const commentCell = bm.comment || "";
const checked = bmSelected.has(bm.id) ? " checked" : "";
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
tr.innerHTML = `<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td><td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td><td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td><td class="bm-col-mode">${bmEsc(bm.mode)}</td><td class="bm-col-bw">${bwCell}</td><td class="bm-col-loc">${bmEsc(locatorCell)}</td><td class="bm-col-cat">${bmEsc(catCell)}</td><td class="bm-col-dec">${bmEsc(decoderCell)}</td><td class="bm-col-cmt">${bmEsc(commentCell)}</td><td class="bm-col-act"><button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` + (canControl ? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button><button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>` : "") + `</td>`;
tbody.appendChild(tr);
});
bmSyncSelectAllCheckbox();
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
if (prevBtn) prevBtn.disabled = page <= 1;
if (nextBtn) nextBtn.disabled = page >= totalPages;
}
function bmChangePage(delta) {
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
if (nextPage === bmCurrentPage) return;
bmCurrentPage = nextPage;
bmRender(bmFilteredList);
}
function bmReadDecoders() {
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 || []);
hostState.decoderRegistry.filter((d) => d.bookmark_selectable).forEach((d) => {
const el = bmOptionalEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id);
});
}
function bmBuildDecoderCheckboxes() {
const container = bmEl("bm-decoder-checkboxes");
if (!container) return;
container.innerHTML = "";
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;
container.appendChild(label);
});
}
function bmOpenForm(bm) {
const wrap = bmEl("bm-form-wrap");
if (!wrap) return;
bmEditScope = bm ? bm.scope || bmScope : null;
bmBuildDecoderCheckboxes();
bmEl("bm-id").value = bm ? bm.id : "";
bmEl("bm-name").value = bm ? bm.name : "";
bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
bmEl("bm-mode").value = bm ? bm.mode : "";
bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
bmEl("bm-locator").value = bm ? bm.locator || "" : "";
bmEl("bm-category-input").value = bm ? bm.category || "" : "";
bmEl("bm-comment").value = bm ? bm.comment || "" : "";
bmWriteDecoders(bm?.decoders ?? []);
bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex";
bmEl("bm-name").focus();
}
function bmCloseForm() {
const wrap = bmEl("bm-form-wrap");
if (wrap) wrap.style.display = "none";
}
function bmPrefillFromStatus() {
const freqHz = hostState.lastFreqHz;
if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz));
}
if (hostState.lastModeName) {
bmEl("bm-mode").value = hostState.lastModeName;
}
if (hostState.currentBandwidthHz > 0) {
bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
}
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) {
e.preventDefault();
const id = bmEl("bm-id").value;
const name = bmEl("bm-name").value.trim();
const freqStr = bmEl("bm-freq").value;
const freq_hz = parseInt(freqStr, 10);
const mode = bmEl("bm-mode").value.trim();
const bwStr = bmEl("bm-bw").value;
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
const locator = bmEl("bm-locator").value.trim().toUpperCase();
const category = bmEl("bm-category-input").value.trim();
const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders();
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) {
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
const invalid = !name ? bmEl("bm-name") : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
invalid?.focus();
return;
}
const body = {
name,
freq_hz,
mode,
bandwidth_hz,
locator: locator || null,
category,
comment,
decoders
};
try {
let resp;
if (id) {
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
} else {
resp = await fetch("/bookmarks" + bmScopeParam(false), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
}
if (!resp.ok) {
const text = await resp.text();
if (resp.status === 409) {
throw new Error("A bookmark for that frequency already exists.");
}
throw new Error(text || `HTTP ${resp.status}`);
}
bmCloseForm();
await bmFetch(bmEl("bm-category-filter").value);
} catch (err) {
console.error("Failed to save bookmark:", err);
if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
}
}
async function bmDelete(id) {
if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
const bm = bmList.find((b) => b.id === id);
const scope = bm ? bm.scope : void 0;
try {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
method: "DELETE"
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await bmFetch(bmEl("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmark:", err);
bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
}
}
function bmApply(bm) {
try {
const modeEl = document.getElementById("mode");
if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
}
if (bm.bandwidth_hz) {
hostState.currentBandwidthHz = bm.bandwidth_hz;
hostCore.syncBandwidthInput(bm.bandwidth_hz);
}
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;
if (!onVirtual) {
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;
if (!bwHandledByVchan) {
await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
}
}
hostCore.setRigFrequency(bm.freq_hz);
})();
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = hostState.decoderRegistry.filter(
(d) => d.activation === "toggle"
);
const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status";
const rigId = hostState.lastActiveRigId;
if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId);
}
const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return;
const st = await statusResp.json();
const toggles = [];
for (const d of allToggleDecoders) {
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
const currentlyOn = !!st[statusKey];
const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp);
let wanted;
if (!compatible) {
wanted = false;
} else if (hasDecoders) {
wanted = bm.decoders?.includes(d.id) ?? false;
} else {
wanted = currentlyOn;
}
if (wanted !== currentlyOn) {
toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
}
}
if (toggles.length) await Promise.all(toggles);
})() : Promise.resolve();
void Promise.all([tunePromise, decoderPromise]).catch((error) => {
console.error("Bookmark apply background error:", error);
});
} catch (err) {
console.error("Failed to apply bookmark:", err);
}
}
bridge.trx.modules.bookmarks = {
get overlayList() {
return bmOverlayList;
},
get overlayRevision() {
return bmOverlayRevision;
},
refreshOverlay: bmFetchOverlay,
invalidateColors() {
bmOverlayRevision += 1;
},
apply: bmApply,
formatFrequency: bmFmtFreq,
fetch: bmFetch,
populateScopePicker: bmPopulateScopePicker
};
function bmUpdateSelectionUi() {
const count = bmSelected.size;
const canCtrl = bmCanControl();
const visible = count > 0 && canCtrl;
const btn = bmEl("bm-del-selected-btn");
const countEl = bmEl("bm-del-selected-count");
if (btn) btn.style.display = visible ? "" : "none";
if (countEl) countEl.textContent = String(count);
const moveWrap = bmEl("bm-move-selected-wrap");
const moveCountEl = bmEl("bm-move-selected-count");
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
if (moveCountEl) moveCountEl.textContent = String(count);
if (visible) bmPopulateMoveTarget();
const selectAllBtn = bmEl("bm-select-all-btn");
if (selectAllBtn && bmCanControl()) {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
}
}
function bmPopulateMoveTarget() {
const sel = bmEl("bm-move-target");
if (!sel) return;
const rigIds = hostState.lastRigIds;
const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value;
sel.innerHTML = "";
if (bmScope !== "general") {
const opt = document.createElement("option");
opt.value = "general";
opt.textContent = "General";
sel.appendChild(opt);
}
rigIds.forEach((id) => {
if (id === bmScope) return;
const opt = document.createElement("option");
opt.value = id;
opt.textContent = displayNames[id] || id;
sel.appendChild(opt);
});
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
sel.value = prev;
}
}
async function bmMoveSelected() {
const ids = Array.from(bmSelected);
if (ids.length === 0) return;
const target = bmEl("bm-move-target")?.value;
if (!target) return;
const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!await bridge.trxUi.confirm({
title: "Move selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
confirmLabel: "Move",
danger: false
})) return;
try {
const byScope = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
if (scope === target) continue;
(byScope[scope] ||= []).push(id);
}
await Promise.all(Object.entries(byScope).map(
([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds, to: target })
}).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
})
));
bmSelected.clear();
bmUpdateSelectionUi();
await bmFetch(bmEl("bm-category-filter").value);
} catch (err) {
console.error("Failed to move bookmarks:", err);
bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
}
}
function bmSyncSelectAllCheckbox() {
const selectAll = bmEl("bm-select-all");
if (!selectAll) return;
const checkboxes = document.querySelectorAll(".bm-row-sel");
if (checkboxes.length === 0) {
selectAll.checked = false;
selectAll.indeterminate = false;
return;
}
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
selectAll.checked = checkedCount === checkboxes.length;
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
}
async function bmDeleteSelected() {
const ids = Array.from(bmSelected);
if (ids.length === 0) return;
if (!await bridge.trxUi.confirm({
title: "Delete selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
confirmLabel: "Delete"
})) return;
try {
const byScope = {};
for (const id of ids) {
const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope;
(byScope[scope] ||= []).push(id);
}
await Promise.all(Object.entries(byScope).map(
([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds })
}).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
})
));
bmSelected.clear();
bmUpdateSelectionUi();
await bmFetch(bmEl("bm-category-filter").value);
} catch (err) {
console.error("Failed to delete bookmarks:", err);
bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
}
}
function bmPopulateScopePicker() {
const picker = bmEl("bm-scope-picker");
if (!picker) return;
const rigIds = hostState.lastRigIds;
const displayNames = hostState.lastRigDisplayNames;
const prev = picker.value;
while (picker.options.length > 1) picker.remove(1);
rigIds.forEach((id) => {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = displayNames[id] || id;
picker.appendChild(opt);
});
if (prev && (prev === "general" || rigIds.includes(prev))) {
picker.value = prev;
} else {
picker.value = "general";
}
bmScope = picker.value;
}
(function initBookmarks() {
bmSyncAccess();
bmBuildDecoderCheckboxes();
hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
bmPopulateScopePicker();
const scopePicker = bmEl("bm-scope-picker");
if (scopePicker) {
scopePicker.addEventListener("change", (e) => {
bmScope = e.currentTarget.value;
void bmFetch(bmEl("bm-category-filter").value || "");
});
}
document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
if (!btn) return;
void bmFetch(bmEl("bm-category-filter").value);
});
bmEl("bm-add-btn").addEventListener("click", () => {
bmOpenForm(null);
bmPrefillFromStatus();
});
bmEl("bm-category-filter").addEventListener("change", (e) => {
void bmFetch(e.currentTarget.value);
});
bmEl("bm-mode-filter").addEventListener("change", () => {
bmApplyFilters();
});
bmEl("bm-text-filter").addEventListener("input", () => {
bmApplyFilters();
});
bmEl("bm-page-prev").addEventListener("click", () => {
bmChangePage(-1);
});
bmEl("bm-page-next").addEventListener("click", () => {
bmChangePage(1);
});
bmEl("bm-form").addEventListener("submit", (event) => {
void bmSave(event);
});
bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = bmEl("bm-form-wrap");
if (formWrap) {
formWrap.addEventListener("click", (e) => {
if (e.target === formWrap) bmCloseForm();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
bmCloseForm();
}
});
bmEl("bm-select-all").addEventListener("change", (e) => {
const checked = e.currentTarget.checked;
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
cb.checked = checked;
const id = cb.dataset.bmId;
if (!id) return;
if (checked) bmSelected.add(id);
else bmSelected.delete(id);
});
bmUpdateSelectionUi();
});
bmEl("bm-select-all-btn").addEventListener("click", () => {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
if (allSelected) {
bmSelected.clear();
} else {
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
}
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
});
bmSyncSelectAllCheckbox();
bmUpdateSelectionUi();
});
bmEl("bm-del-selected-btn").addEventListener("click", () => {
void bmDeleteSelected();
});
bmEl("bm-move-selected-btn").addEventListener("click", () => {
void bmMoveSelected();
});
bmEl("bm-tbody").addEventListener("click", (e) => {
void (async () => {
if (!(e.target instanceof Element)) return;
const checkbox = e.target.closest(".bm-row-sel");
if (checkbox) {
const id = checkbox.dataset.bmId;
if (!id) return;
if (checkbox.checked) bmSelected.add(id);
else bmSelected.delete(id);
bmSyncSelectAllCheckbox();
bmUpdateSelectionUi();
return;
}
const tuneBtn = e.target.closest(".bm-tune-btn");
const editBtn = e.target.closest(".bm-edit-btn");
const delBtn = e.target.closest(".bm-del-btn");
if (tuneBtn) {
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
if (bm) bmApply(bm);
} else if (editBtn) {
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
if (bm) bmOpenForm(bm);
} else if (delBtn) {
const id = delBtn.dataset.bmId;
if (id) await bmDelete(id);
}
})();
});
void bmFetch("");
})();
@@ -0,0 +1,9 @@
// src/plugins/host.ts
var host = window;
var hostState = host.trx.state;
var hostCore = host.trx.core;
export {
hostState,
hostCore
};
@@ -0,0 +1,277 @@
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;
}
function isAlphaNumeric(value) {
return value !== void 0 && /[A-Za-z0-9]/.test(value);
}
function isGrid(value) {
const normalized = value.trim().toUpperCase();
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
}
function escapeFtxHtml(input) {
return input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function extractFtxGrids(message) {
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
}
function tokenize(message) {
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
}
function isCallsign(token) {
return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
}
function extractFtxLocatorDetails(message) {
const tokens = tokenize(message);
const grids = extractFtxGrids(message);
const gridIndex = tokens.findIndex(isGrid);
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
const target = directed ? callsigns[0] ?? null : null;
return grids.map((grid) => ({ grid, station: source, source, target }));
}
function extractFtxCallsign(message) {
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
}
function renderFtxMessage(message) {
let html = "";
let index = 0;
while (index < message.length) {
if (!isAlphaNumeric(message[index])) {
html += escapeFtxHtml(message[index] ?? "");
index += 1;
continue;
}
let end = index + 1;
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
const token = message.slice(index, end);
const grid = token.toUpperCase();
html += isGrid(grid) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : escapeFtxHtml(token);
index = end;
}
return html;
}
function installFtxCompatibilityHelpers() {
bridge.renderFt8Message = renderFtxMessage;
bridge.ft8EscapeHtml = escapeFtxHtml;
bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
bridge.ft8ExtractAllGrids = extractFtxGrids;
bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
}
function initializeFt8FamilyBar() {
const labels = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
const builders = {};
const dismissed = { ft8: 0, ft4: 0, ft2: 0 };
const overlay = document.getElementById("ft8-bar-overlay");
let active = "ft8";
const update = () => {
if (!overlay) return;
const mode = (document.getElementById("mode")?.value ?? "").toUpperCase();
const result = builders[active]?.();
if (mode !== "DIG" && mode !== "USB" || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) {
overlay.style.display = "none";
overlay.innerHTML = "";
return;
}
const label = labels[active];
overlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">&times;</button></span></div>${result.html}`;
overlay.style.display = "flex";
};
bridge.registerFt8FamilyBarRenderer = (decoder, builder) => {
builders[decoder] = builder;
};
bridge.setFt8FamilyBarDecoder = (decoder) => {
active = decoder;
update();
};
bridge.updateFt8Bar = update;
bridge.clearFt8Bar = () => {
bridge.trxPluginRuntime.reset(active);
};
bridge.closeFt8Bar = () => {
dismissed[active] = Date.now();
update();
};
}
function initializeFtxDecoder(config) {
const { id, label, periodMs, periodDigits = 1 } = config;
const status = document.getElementById(`${id}-status`);
const period = document.getElementById(`${id}-period`);
const messagesElement = document.getElementById(`${id}-messages`);
const filterInput = document.getElementById(`${id}-filter`);
let filterText = "";
let history = [];
const renderMessage = (message) => {
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
};
const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5;
const prune = () => {
const cutoff = Date.now() - retentionMs();
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
};
const schedule = (job) => {
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
else job();
};
const displayFrequency = (value) => {
const raw = finiteNumber(value);
if (raw === null) return null;
const base = finiteNumber(bridge.ft8BaseHz);
return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw;
};
const renderRow = (message) => {
const row = document.createElement("div");
row.className = "ft8-row";
const raw = message.message ?? "";
row.dataset.message = raw.toUpperCase();
row.dataset.decoder = id;
const storedFrequency = finiteNumber(message.freq_hz);
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
const snr = finiteNumber(message.snr_db);
const delta = finiteNumber(message.dt_s);
const frequency = displayFrequency(message.freq_hz);
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
return row;
};
const render = () => {
prune();
if (!messagesElement) return;
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
count += 1;
}
messagesElement.replaceChildren(fragment);
};
const normalize = (message) => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message,
freq_hz: frequency ?? message.freq_hz,
locator_details: locatorDetails
});
}
return {
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
dt_s: message.dt_s,
freq_hz: frequency ?? message.freq_hz,
message: message.message,
_tsMs: finiteNumber(message.ts_ms) ?? Date.now()
};
};
const receiveBatch = (messages) => {
if (messages.length === 0) return;
if (status) status.textContent = "Receiving";
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
bridge.updateFt8Bar?.();
schedule(render);
};
const reset = () => {
history = [];
bridge.updateFt8Bar?.();
render();
bridge.clearMapMarkersByType?.(id);
};
const barFrames = () => {
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
let html = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
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);
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
};
bridge.trxPluginRuntime.registerDecoder({
id,
onMessage: (message) => {
receiveBatch([message]);
},
onBatch: receiveBatch,
restore: receiveBatch,
prune: () => {
prune();
render();
},
reset
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = () => {
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`;
};
updatePeriod();
window.setInterval(updatePeriod, 250);
filterInput?.addEventListener("input", () => {
filterText = filterInput.value.trim().toUpperCase();
render();
});
messagesElement?.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
if (grid) {
bridge.navigateToMapLocator?.(grid.toUpperCase(), id);
event.preventDefault();
}
});
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
toggle?.addEventListener("click", () => {
void (async () => {
try {
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
await hostCore.postPath(`/toggle_${id}_decode`);
} catch (error) {
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 hostCore.postPath(`/clear_${id}_decode`);
reset();
} catch (error) {
console.error(`${label} history clear failed`, error);
}
})();
});
}
export {
installFtxCompatibilityHelpers,
initializeFt8FamilyBar,
initializeFtxDecoder
};
@@ -0,0 +1,254 @@
// src/plugins/aprs-shared.ts
function escapeAprsHtml(value) {
return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function aprsPacketCategory(packet) {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position":
return "Position";
case "message":
return "Message";
case "weather":
return "Weather";
case "telemetry":
return "Telemetry";
default:
return "Other";
}
}
function aprsAgeText(timestampMs) {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
function aprsPacketSignature(packet) {
return [
packet.srcCall ?? "",
packet.destCall ?? "",
packet.path ?? "",
packet.info ?? "",
packet.type ?? "",
packet.lat?.toFixed(4) ?? "",
packet.lon?.toFixed(4) ?? ""
].join("|");
}
function collapseAprsDuplicates(packets) {
const seen = /* @__PURE__ */ new Set();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
function aprsHexBytes(bytes) {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function renderAprsInfo(packet) {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte) {
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character) {
const code = character.charCodeAt(0);
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character) {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
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 renderAprsSymbolSlot(packet, escapeHtml) {
return renderLocalAprsSymbol(packet, escapeHtml) || '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
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>`;
}
function normalizeAprsPacket(packet, receiver) {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null
};
}
function fahrenheitToCelsius(fahrenheit) {
return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10;
}
function summarizeAprsWeather(info) {
const parts = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
function summarizeAprsTelemetry(info) {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
function summarizeAprsMessage(info) {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
function summarizeAprsCourseSpeed(info) {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
function summarizeAprsPayload(packet) {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
function renderAprsPacketRow(packet, options = {}) {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderAprsSymbolSlot(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon);
});
});
const copyButton = row.querySelector("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
export {
aprsPacketCategory,
aprsAgeText,
collapseAprsDuplicates,
aprsSymbolSprite,
normalizeAprsPacket,
renderAprsPacketRow
};
@@ -0,0 +1,404 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/cw.ts
var cwWindow = window;
var cwStatusEl = document.getElementById("cw-status");
var cwOutputEl = document.getElementById("cw-output");
var cwAutoInput = document.getElementById("cw-auto");
var cwWpmInput = document.getElementById("cw-wpm");
var cwToneInput = document.getElementById("cw-tone");
var cwSignalIndicator = document.getElementById("cw-signal-indicator");
var cwToneCanvas = document.getElementById("cw-tone-waterfall");
var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
var cwTonePickerEl = document.querySelector(".cw-tone-picker");
var cwToneRangeEl = document.getElementById("cw-tone-range");
var cwBarOverlay = document.getElementById("cw-bar-overlay");
var CW_MAX_LINES = 200;
var CW_TONE_MIN_HZ = 100;
var CW_TONE_MAX_HZ = 1e4;
var CW_WPM_MIN = 5;
var CW_WPM_MAX = 40;
var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
var CW_BAR_LINE_GAP_MS = 5e3;
var cwLastAppendTime = 0;
var cwTonePickerRaf = null;
var cwBarHistory = [];
var cwBarCurrentLine = null;
var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null;
function escapeCwHtml(input) {
return hostCore.escapeMapHtml(input);
}
function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled;
if (cwWpmInput) {
cwWpmInput.disabled = enabled;
cwWpmInput.readOnly = enabled;
}
if (cwToneInput) {
cwToneInput.disabled = enabled;
cwToneInput.readOnly = enabled;
}
if (cwTonePickerEl) {
cwTonePickerEl.classList.toggle("is-auto", enabled);
}
}
cwWindow.applyCwAutoUi = applyCwAutoUi;
cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar() {
if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
return;
}
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearCwBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearCwBar();}" aria-label="Clear CW overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">&times;</button></span></div>`;
for (const line of liveLines.slice(0, 8)) {
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
const meta = [
line.wpm ? `${String(line.wpm)} WPM` : null,
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
}
cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex";
}
cwWindow.updateCwBar = updateCwBar;
cwWindow.clearCwBar = function() {
resetCwHistoryView();
};
cwWindow.closeCwBar = function() {
cwBarDismissedAtMs = Date.now();
if (cwBarOverlay) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
}
};
function clampCwWpm(wpm) {
const numeric = Number(wpm);
if (!Number.isFinite(numeric)) return 15;
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
}
function clampCwTone(tone) {
const numeric = Number(tone);
if (!Number.isFinite(numeric)) return 700;
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
}
function currentCwToneRange() {
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null;
}
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const lowerSideband = mode === "CWR";
const upperSideband = mode === "CW";
if (!lowerSideband && !upperSideband) return null;
const toneMinHz = CW_TONE_MIN_HZ;
const toneMaxHz = CW_TONE_MAX_HZ;
return {
tunedHz,
bandwidthHz,
toneMinHz,
toneMaxHz,
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
lowerSideband,
mode
};
}
function cwToneToRfHz(range, toneHz) {
if (!range) return NaN;
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
}
function toneClampForRange(tone, range) {
const clamped = clampCwTone(tone);
if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
}
function ensureCwToneCanvasResolution() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width);
const cssHeight = Math.round(rect.height);
if (cssWidth < 8 || cssHeight < 8) {
return false;
}
const dpr = window.devicePixelRatio || 1;
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
}
function drawCwTonePicker() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
const width = cwToneCanvas.width;
const height = cwToneCanvas.height;
cwToneGl.clear([0, 0, 0, 0]);
const range = currentCwToneRange();
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
if (cwToneRangeEl) {
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required";
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
cwToneRangeEl.textContent = "Waiting for spectrum";
}
}
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
return;
}
if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
}
const bins = cwWindow.lastSpectrumData.bins;
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
const centerHz = cwWindow.lastSpectrumData.center_hz;
const maxIdx = Math.max(1, bins.length - 1);
const fullLoHz = centerHz - sampleRate / 2;
const tones = new Array(width).fill(-140);
for (let x = 0; x < width; x += 1) {
const frac = width <= 1 ? 0 : x / (width - 1);
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
const rfHz = cwToneToRfHz(range, toneHz);
const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx)));
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
tones[x] = power;
}
const smoothed = new Array(width).fill(-140);
const smoothRadius = Math.max(1, Math.round(width / 180));
for (let x = 0; x < width; x += 1) {
let sum = 0;
let count = 0;
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
if (i < 0 || i >= width) continue;
sum += tones[i] ?? -140;
count += 1;
}
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
}
const sorted = smoothed.slice().sort((a, b) => a - b);
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
const floorDb = Math.min(q20 - 2, q95 - 10);
const ceilDb = Math.max(floorDb + 18, q95 + 2);
const dbSpan = Math.max(1, ceilDb - floorDb);
const yForDb = (db) => {
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
return Math.round((1 - n) * (height - 1));
};
const rootStyle = getComputedStyle(document.documentElement);
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
const parseColor = typeof cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null;
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
const hGridCount = 4;
const gridSegments = [];
for (let i = 1; i <= hGridCount; i += 1) {
const y = Math.round(i / (hGridCount + 1) * (height - 1));
gridSegments.push(0, y, width, y);
}
cwToneGl.drawSegments(gridSegments, axisColor, 1);
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
const tickSegments = [];
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
tickSegments.push(x, 0, x, height);
}
cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = [];
for (let x = 0; x < width; x += 1) {
linePoints.push(x, yForDb(smoothed[x] ?? -140));
}
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
if (cwAutoInput?.checked) {
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
}
}
async function setCwTone(tone, { syncInput = true } = {}) {
const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) {
cwToneInput.value = String(clamped);
}
try {
await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
drawCwTonePicker();
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", () => {
void (async () => {
const enabled = cwAutoInput.checked;
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (error) {
console.error("CW auto toggle failed", error);
} finally {
cwAutoLocalOverride = null;
}
})();
});
}
if (cwWpmInput) {
cwWpmInput.addEventListener("change", () => {
void (async () => {
if (cwAutoInput?.checked) return;
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = String(wpm);
try {
await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
} catch (error) {
console.error("CW WPM set failed", error);
}
})();
});
}
if (cwToneInput) {
cwToneInput.addEventListener("change", () => {
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
});
}
if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", (event) => {
if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return;
const range = currentCwToneRange();
if (!range) return;
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const tone = range.toneMinHz + frac * range.toneSpanHz;
void setCwTone(tone);
});
}
function resetCwHistoryView() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
updateCwBar();
drawCwTonePicker();
}
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 hostCore.postPath("/clear_cw_decode");
resetCwHistoryView();
} catch (error) {
console.error("CW history clear failed", error);
}
})();
});
function onServerCw(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div");
line.className = "cw-line";
cwOutputEl.appendChild(line);
}
cwLastAppendTime = now;
const lastLine = cwOutputEl.lastElementChild;
if (lastLine) {
lastLine.textContent += evt.text;
}
while (cwOutputEl.children.length > CW_MAX_LINES) {
const firstChild = cwOutputEl.firstChild;
if (!firstChild) break;
cwOutputEl.removeChild(firstChild);
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
if (evt.text) {
const now = Date.now();
if (evt.text === "\n") {
cwBarFlushCurrentLine();
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
}
cwBarCurrentLine.text += evt.text;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
}
}
if (cwTonePickerRaf != null) return;
cwTonePickerRaf = requestAnimationFrame(() => {
cwTonePickerRaf = null;
drawCwTonePicker();
});
}
function restoreCwHistory(events) {
if (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) {
onServerCw(evt);
}
}
cwWindow.trxPluginRuntime.registerDecoder({
id: "cw",
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
drawCwTonePicker();
};
window.addEventListener("resize", () => {
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwBar();
ensureCwToneCanvasResolution();
drawCwTonePicker();
@@ -0,0 +1,179 @@
"use strict";
(() => {
// src/decode-history-worker.ts
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
var HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
var workerScope = self;
function decodeCborUint(view, bytes, state, additional) {
const offset = state.offset;
if (additional < 24) return additional;
if (additional === 24) {
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 1;
return bytes[offset] ?? 0;
}
if (additional === 25) {
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 2;
return view.getUint16(offset);
}
if (additional === 26) {
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 4;
return view.getUint32(offset);
}
if (additional === 27) {
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getBigUint64(offset);
state.offset += 8;
const numeric = Number(value);
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
return numeric;
}
throw new Error("Unsupported CBOR additional info");
}
function decodeCborFloat16(bits) {
const sign = bits & 32768 ? -1 : 1;
const exponent = bits >> 10 & 31;
const fraction = bits & 1023;
if (exponent === 0) {
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
}
if (exponent === 31) {
return fraction === 0 ? sign * Infinity : Number.NaN;
}
return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
}
function decodeCborItem(view, bytes, state) {
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
const initial = bytes[state.offset++];
if (initial === void 0) throw new Error("CBOR payload truncated");
const major = initial >> 5;
const additional = initial & 31;
if (major === 0) return decodeCborUint(view, bytes, state, additional);
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
if (major === 2) {
const length = decodeCborUint(view, bytes, state, additional);
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
const chunk = bytes.slice(state.offset, state.offset + length);
state.offset += length;
return Array.from(chunk);
}
if (major === 3) {
const length = decodeCborUint(view, bytes, state, additional);
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
const chunk = bytes.subarray(state.offset, state.offset + length);
state.offset += length;
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
}
if (major === 4) {
const length = decodeCborUint(view, bytes, state, additional);
const items = new Array(length);
for (let i = 0; i < length; i += 1) {
items[i] = decodeCborItem(view, bytes, state);
}
return items;
}
if (major === 5) {
const length = decodeCborUint(view, bytes, state, additional);
const value = {};
for (let i = 0; i < length; i += 1) {
const key = decodeCborItem(view, bytes, state);
const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean" ? String(key) : JSON.stringify(key);
value[property] = decodeCborItem(view, bytes, state);
}
return value;
}
if (major === 6) {
decodeCborUint(view, bytes, state, additional);
return decodeCborItem(view, bytes, state);
}
if (major === 7) {
if (additional === 20) return false;
if (additional === 21) return true;
if (additional === 22) return null;
if (additional === 23) return void 0;
if (additional === 25) {
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
const bits = view.getUint16(state.offset);
state.offset += 2;
return decodeCborFloat16(bits);
}
if (additional === 26) {
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getFloat32(state.offset);
state.offset += 4;
return value;
}
if (additional === 27) {
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
const value = view.getFloat64(state.offset);
state.offset += 8;
return value;
}
}
throw new Error("Unsupported CBOR major type");
}
function decodeCborPayload(buffer) {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const state = { offset: 0 };
const value = decodeCborItem(view, bytes, state);
if (state.offset !== bytes.length) {
throw new Error("Unexpected trailing bytes in decode history payload");
}
return value;
}
function isHistory(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function fetchAndDecodeHistory(url, batchLimit) {
workerScope.postMessage({ type: "status", phase: "fetching" });
const resp = await fetch(url, { credentials: "same-origin" });
if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`);
const payload = await resp.arrayBuffer();
if (payload.byteLength === 0) {
workerScope.postMessage({ type: "start", total: 0 });
workerScope.postMessage({ type: "done", total: 0 });
return;
}
workerScope.postMessage({ type: "status", phase: "decoding" });
const history = decodeCborPayload(payload);
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : [];
return sum + items.length;
}, 0);
workerScope.postMessage({ type: "start", total });
let processed = 0;
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
for (const kind of HISTORY_GROUP_KEYS) {
const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : [];
if (items.length === 0) continue;
for (let index = 0; index < items.length; index += safeLimit) {
const messages = items.slice(index, index + safeLimit);
processed += messages.length;
workerScope.postMessage({
type: "group",
kind,
messages,
processed,
total
});
}
}
workerScope.postMessage({ type: "done", total });
}
function isFetchHistoryRequest(value) {
return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history";
}
workerScope.onmessage = (event) => {
const data = event.data;
if (!isFetchHistoryRequest(data)) return;
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((error) => {
workerScope.postMessage({
type: "error",
message: error instanceof Error ? error.message : typeof error === "string" ? error : "unknown worker failure"
});
});
};
})();
@@ -0,0 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft2.ts
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
@@ -0,0 +1,7 @@
import {
initializeFtxDecoder
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft4.ts
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
@@ -0,0 +1,11 @@
import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers
} from "./chunk-O2Y7YEVQ.js";
import "./chunk-KL66PICH.js";
// src/plugins/ft8.ts
installFtxCompatibilityHelpers();
initializeFt8FamilyBar();
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 });
@@ -0,0 +1,241 @@
import {
aprsAgeText,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow
} from "./chunk-OPEIVJGD.js";
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/hf-aprs.ts
var hfAprsWindow = window;
var hfAprsStatus = document.getElementById("hf-aprs-status");
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
var hfAprsFilterText = "";
var hfAprsPacketHistory = [];
var hfAprsOnlyPos = false;
var hfAprsHideCrc = false;
var hfAprsCollapseDup = false;
var hfAprsTypeFilter = "all";
function currentHfAprsHistoryRetentionMs() {
return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
}
function pruneHfAprsPacketHistory() {
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
}
function scheduleHfAprsHistoryRender() {
if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") {
hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => {
renderHfAprsHistory();
});
return;
}
renderHfAprsHistory();
}
function hfAprsDistanceText(pkt) {
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`;
}
function hfAprsFilterMatch(pkt) {
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (hfAprsHideCrc && !pkt.crcOk) return false;
if (hfAprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
if (!hfAprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
aprsPacketCategory(pkt)
].filter(Boolean).join(" ").toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
var collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
}
}
}
function updateHfAprsChipState() {
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
});
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
function renderHfAprsRow(pkt, isFresh) {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat, lon) => {
hfAprsWindow.navigateToAprsMap?.(lat, lon);
},
onCopy: (text) => {
void copyHfAprsCoords(text);
}
});
}
async function copyHfAprsCoords(text) {
try {
const clipboard = Reflect.get(navigator, "clipboard");
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
} catch {
hostCore.showHint("Copy failed", 1500);
}
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
if (!hfAprsPacketsEl) {
updateHfAprsSummary();
updateHfAprsChipState();
return;
}
const visible = hfAprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (const [index, packet] of visible.entries()) {
fragment.appendChild(renderHfAprsRow(packet, index === 0));
}
hfAprsPacketsEl.replaceChildren(fragment);
updateHfAprsSummary();
updateHfAprsChipState();
}
function resetHfAprsHistoryView() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
}
function pruneHfAprsHistoryView() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
}
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt) {
return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
}
function onServerHfAprsBatch(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
normalized.push(next);
}
normalized.reverse();
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
}
var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
try {
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await hostCore.postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
})();
});
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 hostCore.postPath("/clear_hf_aprs_decode");
resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
}
})();
});
if (hfAprsOnlyPosBtn) {
hfAprsOnlyPosBtn.addEventListener("click", () => {
hfAprsOnlyPos = !hfAprsOnlyPos;
renderHfAprsHistory();
});
}
if (hfAprsHideCrcBtn) {
hfAprsHideCrcBtn.addEventListener("click", () => {
hfAprsHideCrc = !hfAprsHideCrc;
renderHfAprsHistory();
});
}
if (hfAprsCollapseDupBtn) {
hfAprsCollapseDupBtn.addEventListener("click", () => {
hfAprsCollapseDup = !hfAprsCollapseDup;
renderHfAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`hf-aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
hfAprsTypeFilter = type;
renderHfAprsHistory();
});
});
if (hfAprsFilterInput) {
hfAprsFilterInput.addEventListener("input", () => {
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
renderHfAprsHistory();
});
}
function onServerHfAprs(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
}
renderHfAprsHistory();
window.trxPluginRuntime.registerDecoder({
id: "hf_aprs",
onMessage: onServerHfAprs,
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,263 @@
// src/plugins/sat-scheduler.ts
var satSchedulerWindow = window;
(function() {
"use strict";
const dom = {
enabled: document.getElementById("scheduler-sat-enabled"),
pretune: document.getElementById("scheduler-sat-pretune"),
body: document.getElementById("scheduler-sat-body"),
tbody: document.getElementById("scheduler-sat-tbody"),
addBtn: document.getElementById("scheduler-sat-add-btn"),
passStatus: document.getElementById("scheduler-sat-pass-status"),
formWrap: document.getElementById("sch-sat-form-wrap"),
formTitle: document.getElementById("sch-sat-form-title"),
form: document.getElementById("sch-sat-form"),
formCancel: document.getElementById("sch-sat-form-cancel"),
preset: document.getElementById("scheduler-sat-preset"),
name: document.getElementById("scheduler-sat-name"),
norad: document.getElementById("scheduler-sat-norad"),
bookmark: document.getElementById("scheduler-sat-bookmark"),
minEl: document.getElementById("scheduler-sat-min-el"),
priority: document.getElementById("scheduler-sat-priority"),
centerHz: document.getElementById("scheduler-sat-center-hz")
};
let editIdx = null;
let eventsWired = false;
function getBridge() {
return satSchedulerWindow.trx?.modules?.scheduler ?? null;
}
function getConfig() {
const b = getBridge();
return b?.getConfig() ?? null;
}
function getStatus() {
const b = getBridge();
return b?.getStatus() ?? null;
}
function getBookmarks() {
const b = getBridge();
return b?.getBookmarks() ?? [];
}
function markDirty() {
getBridge()?.markDirty();
}
function bmName(id) {
const bm = getBookmarks().find(function(b) {
return b.id === id;
});
return bm ? bm.name : id;
}
function escHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function formatFreq(hz) {
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
return `${String(hz)} Hz`;
}
function getSatelliteEntries() {
const config = getConfig();
return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : [];
}
function ensureSatelliteConfig() {
const config = getConfig();
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
return config.satellites;
}
function collectSatelliteConfig() {
const enabled = dom.enabled ? dom.enabled.checked : false;
const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
return {
enabled,
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
entries: getSatelliteEntries()
};
}
function renderSection() {
const config = getConfig();
const satCfg = config?.satellites;
const enabled = satCfg?.enabled ?? false;
if (dom.enabled) dom.enabled.checked = enabled;
if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60);
if (dom.body) dom.body.style.display = enabled ? "" : "none";
renderEntries();
renderPassStatus();
}
function renderEntries() {
if (!dom.tbody) return;
const entries = getSatelliteEntries();
const frag = document.createDocumentFragment();
entries.forEach(function(entry, idx) {
const tr = document.createElement("tr");
const tdSat = document.createElement("td");
tdSat.textContent = entry.satellite || "";
tr.appendChild(tdSat);
const tdNorad = document.createElement("td");
tdNorad.textContent = String(entry.norad_id || "");
tr.appendChild(tdNorad);
const tdBm = document.createElement("td");
tdBm.textContent = bmName(entry.bookmark_id);
tr.appendChild(tdBm);
const tdEl = document.createElement("td");
tdEl.textContent = `${String(entry.min_elevation_deg)}°`;
tr.appendChild(tdEl);
const tdPrio = document.createElement("td");
tdPrio.textContent = String(entry.priority || 0);
tr.appendChild(tdPrio);
const tdActions = document.createElement("td");
const editBtn = document.createElement("button");
editBtn.className = "sch-write";
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", function() {
openForm(entry, idx);
});
tdActions.appendChild(editBtn);
const removeBtn = document.createElement("button");
removeBtn.className = "sch-write";
removeBtn.type = "button";
removeBtn.textContent = "Remove";
removeBtn.addEventListener("click", function() {
removeEntry(idx);
});
tdActions.appendChild(removeBtn);
tr.appendChild(tdActions);
frag.appendChild(tr);
});
dom.tbody.replaceChildren(frag);
}
function renderPassStatus() {
if (!dom.passStatus) return;
const entries = getSatelliteEntries();
if (entries.length === 0) {
dom.passStatus.innerHTML = "";
return;
}
const status = getStatus();
if (status && status.active_satellite) {
dom.passStatus.innerHTML = '<span class="sch-sat-active-badge">PASS ACTIVE: ' + escHtml(status.active_satellite) + "</span>";
} else {
dom.passStatus.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
}
}
function renderBookmarkSelect(selectedId) {
const bookmarkSelect = dom.bookmark;
if (!bookmarkSelect) return;
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
getBookmarks().forEach(function(bm) {
const opt = document.createElement("option");
opt.value = bm.id;
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
if (bm.id === selectedId) opt.selected = true;
bookmarkSelect.appendChild(opt);
});
}
function removeEntry(idx) {
const sat = ensureSatelliteConfig();
sat.entries.splice(idx, 1);
renderEntries();
markDirty();
}
function openForm(entry, idx) {
editIdx = idx != null ? idx : null;
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
if (dom.preset) dom.preset.value = "";
if (dom.name) dom.name.value = entry ? entry.satellite || "" : "";
if (dom.norad) dom.norad.value = entry ? String(entry.norad_id || "") : "";
if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5);
if (dom.priority) dom.priority.value = String(entry?.priority ?? 0);
if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(entry.center_hz) : "";
renderBookmarkSelect(entry ? entry.bookmark_id : null);
if (dom.formWrap) {
dom.formWrap.style.display = "flex";
if (dom.name) dom.name.focus();
}
}
function closeForm() {
if (dom.formWrap) dom.formWrap.style.display = "none";
editIdx = null;
}
function onFormSubmit(e) {
e.preventDefault();
const satellite = dom.name ? dom.name.value.trim() : "";
const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
const bmId = dom.bookmark ? dom.bookmark.value : "";
if (!satellite) {
satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" });
dom.name?.focus();
return;
}
if (isNaN(noradId) || noradId <= 0) {
satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" });
dom.norad?.focus();
return;
}
if (!bmId) {
satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" });
dom.bookmark?.focus();
return;
}
const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
const sat = ensureSatelliteConfig();
const existing = editIdx !== null ? sat.entries[editIdx] : void 0;
const entryData = {
id: existing?.id ?? `sat_${Date.now().toString(36)}`,
satellite,
norad_id: noradId,
bookmark_id: bmId,
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
priority: isNaN(prio) ? 0 : prio,
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
bookmark_ids: []
};
if (editIdx !== null) {
sat.entries[editIdx] = entryData;
} else {
sat.entries.push(entryData);
}
closeForm();
renderEntries();
markDirty();
}
function onPresetChange() {
if (!dom.preset || !dom.preset.value) return;
const parts = dom.preset.value.split("|");
if (dom.name) dom.name.value = parts[0] || "";
if (dom.norad) dom.norad.value = parts[1] || "";
}
function wireEvents() {
if (eventsWired) return;
eventsWired = true;
if (dom.enabled) {
const enabledInput = dom.enabled;
dom.enabled.addEventListener("change", function() {
if (dom.body) dom.body.style.display = enabledInput.checked ? "" : "none";
markDirty();
});
}
if (dom.pretune) {
dom.pretune.addEventListener("input", function() {
markDirty();
});
}
if (dom.addBtn) dom.addBtn.addEventListener("click", function() {
openForm(null, null);
});
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
}
satSchedulerWindow.satScheduler = {
wireEvents,
renderSection,
renderPassStatus,
collectSatelliteConfig
};
if (getBridge()) {
wireEvents();
renderSection();
}
})();
@@ -0,0 +1,483 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/sat.ts
var satWindow = window;
var satDom = {
status: document.getElementById("sat-status"),
liveView: document.getElementById("sat-live-view"),
historyView: document.getElementById("sat-history-view"),
predictionsView: document.getElementById("sat-predictions-view"),
liveLatest: document.getElementById("sat-live-latest"),
historyList: document.getElementById("sat-history-list"),
historyCount: document.getElementById("sat-history-count"),
filterInput: document.getElementById("sat-filter"),
sortSelect: document.getElementById("sat-sort"),
typeFilter: document.getElementById("sat-type-filter"),
lrptState: document.getElementById("sat-lrpt-state"),
viewLiveBtn: document.getElementById("sat-view-live"),
viewHistoryBtn: document.getElementById("sat-view-history"),
viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter"),
predMinEl: document.getElementById("sat-pred-min-el"),
predCategory: document.getElementById("sat-pred-category"),
predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
predStatus: document.getElementById("sat-pred-status")
};
var satImageHistory = [];
var SAT_MAX_IMAGES = 100;
var SAT_PRED_PAGE_SIZE = 50;
var satPredShowAll = false;
var satFilterText = "";
var satActiveView = "live";
var satPredData = [];
var satPredFilterText = "";
var satPredMinEl = 0;
var satPredCategory = "all";
var satPredSatCount = 0;
var satPredCountdownTimer = null;
function scheduleSatUi(key, job) {
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
satWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function switchSatView(view) {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
if (leavingPredictions) clearPredictionDom();
if (view === "history") {
renderSatHistoryTable();
} else if (view === "predictions") {
satPredShowAll = false;
void loadSatPredictions();
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
satWindow.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => {
switchSatView("live");
});
satDom.viewHistoryBtn?.addEventListener("click", () => {
switchSatView("history");
});
satDom.viewPredBtn?.addEventListener("click", () => {
switchSatView("predictions");
});
var lastSatLrptOn = null;
satWindow.updateSatLiveState = function(update) {
if (!satDom.lrptState) return;
const lrptOn = !!update.lrpt_decode_enabled;
if (lrptOn !== lastSatLrptOn) {
lastSatLrptOn = lrptOn;
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
if (satDom.status) {
if (lrptOn) {
satDom.status.textContent = "Decoder active — waiting for signal";
} else {
satDom.status.textContent = "Decoder idle";
}
}
}
};
function renderSatLatestCard() {
if (!satDom.liveLatest) return;
if (satImageHistory.length === 0) {
satDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
return;
}
const img = satImageHistory[0];
if (!img) return;
const typeName = "Meteor LRPT";
const satellite = img.satellite || "";
const channels = img.channels || img.channel_a || "";
const lines = img.mcu_count || img.line_count || 0;
const unit = "MCU rows";
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
const meta = [typeName];
if (satellite) meta.push(satellite);
if (channels) meta.push(channels);
meta.push(`${lines} ${unit}`);
meta.push(`${date} ${ts}`);
let html = `<div class="sat-latest-card">`;
html += `<div class="sat-latest-title">Latest decoded image</div>`;
html += `<div class="sat-latest-meta">${meta.join(" &middot; ")}</div>`;
if (img.path) {
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
}
if (img.geo_bounds) {
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
}
html += `</div>`;
satDom.liveLatest.innerHTML = html;
}
function getSatFilteredHistory() {
let items = satImageHistory;
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
if (satFilterText) {
items = items.filter((i) => {
const haystack = [
"meteor lrpt",
i.satellite || "",
i.channels || "",
i.channel_a || "",
i.channel_b || ""
].join(" ").toUpperCase();
return haystack.includes(satFilterText);
});
}
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
if (sortVal === "oldest") items = items.slice().reverse();
return items;
}
function renderSatHistoryRow(img) {
const row = document.createElement("div");
row.className = "sat-history-row";
const typeName = "Meteor LRPT";
const typeClass = "sat-type-lrpt";
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
const satellite = img.satellite || "--";
const channels = img.channels || "--";
const lines = img.mcu_count || img.line_count || 0;
const unit = "MCU";
let link = img.path ? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>` : "--";
if (img.geo_bounds) {
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
}
row.innerHTML = [
`<span>${date} ${ts}</span>`,
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
`<span>${satellite}</span>`,
`<span>${channels}</span>`,
`<span>${lines} ${unit}</span>`,
`<span>${link}</span>`
].join("");
return row;
}
function renderSatHistoryTable() {
if (!satDom.historyList) return;
const items = getSatFilteredHistory();
const fragment = document.createDocumentFragment();
for (const image of items) {
fragment.appendChild(renderSatHistoryRow(image));
}
satDom.historyList.replaceChildren(fragment);
if (satDom.historyCount) {
const total = satImageHistory.length;
const shown = items.length;
satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`;
}
}
function addSatImage(img, decoder) {
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
img._tsMs = tsMs;
img._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
img._decoder = decoder;
satImageHistory.unshift(img);
if (satImageHistory.length > SAT_MAX_IMAGES) {
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
}
scheduleSatUi("sat-latest", () => {
renderSatLatestCard();
});
if (satActiveView === "history") {
scheduleSatUi("sat-history", () => {
renderSatHistoryTable();
});
}
}
function onServerLrptProgress(msg) {
if (satDom.status && (msg.mcu_count ?? 0) > 0) {
satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
}
}
function onServerLrptImage(msg) {
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
addSatImage(msg, "lrpt");
if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
satWindow.addSatMapOverlay(msg);
}
}
function resetSatHistoryView() {
satImageHistory = [];
if (satDom.historyList) satDom.historyList.innerHTML = "";
renderSatLatestCard();
renderSatHistoryTable();
satWindow.clearSatMapOverlays?.();
}
function pruneSatHistoryView() {
renderSatHistoryTable();
renderSatLatestCard();
}
satWindow.trxPluginRuntime.registerDecoder({
id: "lrpt_image",
onMessage: onServerLrptImage,
reset: resetSatHistoryView,
prune: pruneSatHistoryView
});
satWindow.trxPluginRuntime.registerDecoder({
id: "lrpt_progress",
onMessage: onServerLrptProgress
});
var lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
try {
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await hostCore.postPath("/toggle_lrpt_decode");
} catch (e) {
console.error("LRPT toggle failed", e);
}
})();
});
var satFilterInput = satDom.filterInput;
satFilterInput?.addEventListener("input", () => {
satFilterText = satFilterInput.value.trim().toUpperCase();
renderSatHistoryTable();
});
satDom.sortSelect?.addEventListener("change", () => {
renderSatHistoryTable();
});
satDom.typeFilter?.addEventListener("change", () => {
renderSatHistoryTable();
});
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 hostCore.postPath("/clear_lrpt_decode");
resetSatHistoryView();
} catch (e) {
console.error("Weather satellite history clear failed", e);
}
})();
});
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8] ?? "N";
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__PURE__ */ new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`;
}
function formatPredDuration(s) {
if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`;
}
function formatCountdown(ms) {
const totalSec = Math.max(0, Math.floor(ms / 1e3));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function elevationClass(deg) {
if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low";
}
function stopCountdownTimer() {
if (satPredCountdownTimer) {
clearInterval(satPredCountdownTimer);
satPredCountdownTimer = null;
}
}
function startCountdownTimer(container) {
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return;
}
const n = Date.now();
let anyActive = false;
for (const el of countdownEls) {
const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n;
if (rem > 0) {
el.textContent = formatCountdown(rem);
anyActive = true;
} else {
el.textContent = "0:00";
}
}
if (!anyActive) {
stopCountdownTimer();
renderSatPredictions(getFilteredPredictions());
}
}, 1e3);
}
function buildCurrentPassRow(pass, now) {
const row = document.createElement("div");
row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
const remaining = Math.max(0, pass.los_ms - now);
row.innerHTML = [
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function buildUpcomingPassRow(pass) {
const row = document.createElement("div");
row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`;
row.innerHTML = [
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
`<span class="sat-pred-col-dir">${dir}</span>`
].join("");
return row;
}
function getFilteredPredictions() {
let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
return items;
}
function applyPredFilters() {
renderSatPredictions(getFilteredPredictions());
}
var satPredictionFilter = satDom.predFilter;
satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters();
});
var satPredictionMinElevation = satDom.predMinEl;
satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters();
});
var satPredictionCategory = satDom.predCategory;
satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters();
});
function renderSatPredictions(passes, error) {
stopCountdownTimer();
if (error) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = error;
return;
}
if (!Array.isArray(passes) || passes.length === 0) {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
return;
}
const now = Date.now();
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
const upcoming = passes.filter((p) => p.aos_ms > now);
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
if (satDom.predCurrentList) {
if (current.length === 0) {
satDom.predCurrentList.innerHTML = "";
} else {
const frag = document.createDocumentFragment();
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
satDom.predCurrentList.replaceChildren(frag);
}
}
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
const hiddenCount = upcoming.length - visibleUpcoming.length;
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
if (satDom.predUpcomingList) {
const frag = document.createDocumentFragment();
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
if (hiddenCount > 0) {
const moreRow = document.createElement("div");
moreRow.className = "sat-pred-row";
moreRow.style.cursor = "pointer";
moreRow.style.textAlign = "center";
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
moreRow.addEventListener("click", () => {
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
});
frag.appendChild(moreRow);
}
satDom.predUpcomingList.replaceChildren(frag);
}
if (satDom.predStatus) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
satDom.predStatus.textContent = text;
}
if (current.length > 0 && satActiveView === "predictions") {
startCountdownTimer(satDom.predCurrentList);
}
}
async function loadSatPredictions() {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try {
const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
satPredSatCount = data.satellite_count || 0;
if (data.error) {
satPredData = [];
renderSatPredictions([], data.error);
} else {
satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
}
} catch (error) {
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
}
}
satWindow.satShowOnMap = function(south, west, north, east) {
if (typeof satWindow.enableMapSourceFilter === "function") {
satWindow.enableMapSourceFilter("sat");
}
const lat = (south + north) / 2;
const lon = (west + east) / 2;
if (satWindow.navigateToAprsMap) {
satWindow.navigateToAprsMap(lat, lon);
}
};
renderSatLatestCard();
renderSatHistoryTable();
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,8 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space> // src/screenshot.ts
// var screenshotWindow = window;
// SPDX-License-Identifier: GPL-2.0-or-later (function() {
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
// Communicates with app.js core via window.trx namespace.
(function () {
"use strict"; "use strict";
const T = window.trx; const T = screenshotWindow.trx;
function isVisibleForSnapshot(el) { function isVisibleForSnapshot(el) {
if (!el) return false; if (!el) return false;
const style = getComputedStyle(el); const style = getComputedStyle(el);
@@ -17,7 +12,6 @@
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0; return rect.width > 0 && rect.height > 0;
} }
function drawRoundedRectPath(ctx, x, y, w, h, r) { function drawRoundedRectPath(ctx, x, y, w, h, r) {
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2)); const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
ctx.beginPath(); ctx.beginPath();
@@ -32,7 +26,6 @@
ctx.quadraticCurveTo(x, y, x + radius, y); ctx.quadraticCurveTo(x, y, x + radius, y);
ctx.closePath(); ctx.closePath();
} }
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) { function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
if (!isVisibleForSnapshot(el)) return null; if (!isVisibleForSnapshot(el)) return null;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
@@ -45,30 +38,29 @@
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)"); const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0); const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)"); const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
const bgAlpha = Math.min(bg[3], maxAlpha); const bgAlpha = Math.min(bg[3], maxAlpha);
if (bgAlpha > 0.01) { if (bgAlpha > 0.01) {
drawRoundedRectPath(ctx, x, y, w, h, radius); drawRoundedRectPath(ctx, x, y, w, h, radius);
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`; ctx.fillStyle = `rgba(${String(Math.round(bg[0]))}, ${String(Math.round(bg[1]))}, ${String(Math.round(bg[2]))}, ${String(bgAlpha)})`;
ctx.fill(); ctx.fill();
} }
const borderAlpha = Math.min(border[3], maxAlpha); const borderAlpha = Math.min(border[3], maxAlpha);
if (borderWidth > 0 && borderAlpha > 0.01) { if (borderWidth > 0 && borderAlpha > 0.01) {
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5)); drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
ctx.lineWidth = borderWidth; ctx.lineWidth = borderWidth;
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`; ctx.strokeStyle = `rgba(${String(Math.round(border[0]))}, ${String(Math.round(border[1]))}, ${String(Math.round(border[2]))}, ${String(borderAlpha)})`;
ctx.stroke(); ctx.stroke();
} }
return { x, y, w, h, style }; return { x, y, w, h, style };
} }
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) { function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
const words = String(text || "").split(/\s+/).filter(Boolean); const words = (text || "").split(/\s+/).filter(Boolean);
if (!words.length) return; if (!words.length) return;
let line = ""; let line = "";
let lineIdx = 0; let lineIdx = 0;
for (let i = 0; i < words.length; i += 1) { for (let i = 0; i < words.length; i += 1) {
const candidate = line ? `${line} ${words[i]}` : words[i]; const word = words[i] ?? "";
const candidate = line ? `${line} ${word}` : word;
if (ctx.measureText(candidate).width <= maxWidth || !line) { if (ctx.measureText(candidate).width <= maxWidth || !line) {
line = candidate; line = candidate;
continue; continue;
@@ -76,13 +68,12 @@
ctx.fillText(line, x, y + lineIdx * lineHeight); ctx.fillText(line, x, y + lineIdx * lineHeight);
lineIdx += 1; lineIdx += 1;
if (lineIdx >= maxLines) return; if (lineIdx >= maxLines) return;
line = words[i]; line = word;
} }
if (line && lineIdx < maxLines) { if (line && lineIdx < maxLines) {
ctx.fillText(line, x, y + lineIdx * lineHeight); ctx.fillText(line, x, y + lineIdx * lineHeight);
} }
} }
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) { function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha); const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
if (!chrome) return; if (!chrome) return;
@@ -91,7 +82,7 @@
if (!clean) return; if (!clean) return;
const style = chrome.style; const style = chrome.style;
const fontSize = parseFloat(style.fontSize) || 12; const fontSize = parseFloat(style.fontSize) || 12;
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25); const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.25;
const padX = 6; const padX = 6;
const padY = 4; const padY = 4;
const maxWidth = Math.max(20, chrome.w - padX * 2); const maxWidth = Math.max(20, chrome.w - padX * 2);
@@ -110,12 +101,11 @@
chrome.y + padY + lineCursor * lineHeight, chrome.y + padY + lineCursor * lineHeight,
maxWidth, maxWidth,
lineHeight, lineHeight,
maxLines - lineCursor, maxLines - lineCursor
); );
lineCursor += 1; lineCursor += 1;
} }
} }
function drawAxisLabels(ctx, axisEl, rootRect) { function drawAxisLabels(ctx, axisEl, rootRect) {
if (!isVisibleForSnapshot(axisEl)) return; if (!isVisibleForSnapshot(axisEl)) return;
for (const node of axisEl.children) { for (const node of axisEl.children) {
@@ -132,7 +122,6 @@
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2); ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
} }
} }
function buildSpectrumSnapshotCanvas() { function buildSpectrumSnapshotCanvas() {
const rootEl = document.querySelector(".signal-visual-block"); const rootEl = document.querySelector(".signal-visual-block");
const spectrumPanelEl = document.getElementById("spectrum-panel"); const spectrumPanelEl = document.getElementById("spectrum-panel");
@@ -145,8 +134,7 @@
try { try {
if (typeof gl.flush === "function") gl.flush(); if (typeof gl.flush === "function") gl.flush();
if (typeof gl.finish === "function") gl.finish(); if (typeof gl.finish === "function") gl.finish();
} catch (_) { } catch {
// Ignore transient WebGL state errors and capture the last good frame.
} }
} }
const rootRect = rootEl.getBoundingClientRect(); const rootRect = rootEl.getBoundingClientRect();
@@ -157,11 +145,9 @@
const ctx = out.getContext("2d"); const ctx = out.getContext("2d");
if (!ctx) return null; if (!ctx) return null;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000"; const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
ctx.fillStyle = bg; ctx.fillStyle = bg;
ctx.fillRect(0, 0, rootRect.width, rootRect.height); ctx.fillRect(0, 0, rootRect.width, rootRect.height);
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas]; const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
for (const canvas of canvases) { for (const canvas of canvases) {
@@ -172,28 +158,21 @@
rect.left - rootRect.left, rect.left - rootRect.left,
rect.top - rootRect.top, rect.top - rootRect.top,
rect.width, rect.width,
rect.height, rect.height
); );
} }
// Decoder overlays over the signal view.
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
// replicated on canvas, so frosted-glass overlays would otherwise obscure
// the spectrum).
const decoderOverlayIds = [ const decoderOverlayIds = [
"ais-bar-overlay", "ais-bar-overlay",
"vdes-bar-overlay", "vdes-bar-overlay",
"ft8-bar-overlay", "ft8-bar-overlay",
"aprs-bar-overlay", "aprs-bar-overlay",
"rds-ps-overlay", "rds-ps-overlay"
]; ];
for (const id of decoderOverlayIds) { for (const id of decoderOverlayIds) {
const overlayEl = document.getElementById(id); const overlayEl = document.getElementById(id);
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue; if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35); drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
} }
// Spectrum axis labels and bookmark chips (includes freq bar).
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
const spectrumDbAxis = document.getElementById("spectrum-db-axis"); const spectrumDbAxis = document.getElementById("spectrum-db-axis");
drawAxisLabels(ctx, spectrumFreqAxis, rootRect); drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
@@ -201,10 +180,8 @@
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect); drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect); drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect); drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
return out; return out;
} }
function clickCanvasDownload(href, fileName) { function clickCanvasDownload(href, fileName) {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = href; a.href = href;
@@ -213,9 +190,10 @@
a.style.display = "none"; a.style.display = "none";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
requestAnimationFrame(() => a.remove()); requestAnimationFrame(() => {
a.remove();
});
} }
function saveCanvasAsPng(canvas, fileName) { function saveCanvasAsPng(canvas, fileName) {
if (!canvas) return Promise.resolve(false); if (!canvas) return Promise.resolve(false);
if (typeof canvas.toBlob === "function") { if (typeof canvas.toBlob === "function") {
@@ -228,10 +206,12 @@
} }
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
clickCanvasDownload(url, fileName); clickCanvasDownload(url, fileName);
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => {
URL.revokeObjectURL(url);
}, 1e3);
resolve(true); resolve(true);
}, "image/png"); }, "image/png");
} catch (_) { } catch {
resolve(false); resolve(false);
} }
}); });
@@ -239,27 +219,24 @@
try { try {
clickCanvasDownload(canvas.toDataURL("image/png"), fileName); clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
return Promise.resolve(true); return Promise.resolve(true);
} catch (_) { } catch {
return Promise.resolve(false); return Promise.resolve(false);
} }
} }
async function captureSpectrumScreenshot() { async function captureSpectrumScreenshot() {
const snapshotCanvas = buildSpectrumSnapshotCanvas(); const snapshotCanvas = buildSpectrumSnapshotCanvas();
if (!snapshotCanvas) { if (!snapshotCanvas) {
T.showHint("Spectrum view not ready", 1300); T.showHint("Spectrum view not ready", 1300);
return false; return false;
} }
const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`); const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800); T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
return saved; return saved;
} }
screenshotWindow.trx.modules.screenshot = {
// Register module API
window.trx.modules.screenshot = {
captureSpectrumScreenshot, captureSpectrumScreenshot,
buildSpectrumSnapshotCanvas, buildSpectrumSnapshotCanvas,
saveCanvasAsPng, saveCanvasAsPng
}; };
})(); })();
@@ -0,0 +1,454 @@
import {
hostCore,
hostState
} from "./chunk-KL66PICH.js";
// src/plugins/vchan.ts
var vchanWindow = window;
var vchanSessionId = null;
var vchanRigId = null;
var vchanChannels = [];
var vchanActiveId = null;
var schedulerReleaseState = null;
var schedulerReleasePollTimer = null;
function vchanFmtFreq(hz) {
if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "GHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "kHz";
return `${String(hz)}Hz`;
}
function schedulerReleaseSummaryText(state) {
if (!state) return "Scheduler is controlling the rig.";
const connected = Number(state.connected_sessions) || 0;
const released = Number(state.released_sessions) || 0;
if (connected === 0) return "Scheduler can control the rig.";
if (state.all_released) {
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
}
if (!state.current_session_released) {
const othersReleased = Math.max(released, 0);
return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler.";
}
const blocking = Math.max(connected - released, 0);
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
}
function vchanRenderSchedulerRelease() {
const btn = document.getElementById("scheduler-release-btn");
const status = document.getElementById("scheduler-release-status");
if (!btn || !status) return;
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
btn.disabled = !vchanSessionId || currentReleased;
btn.classList.toggle("active", !currentReleased);
btn.textContent = "Release to Scheduler";
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
}
async function vchanPollSchedulerRelease() {
if (!vchanSessionId) {
schedulerReleaseState = null;
vchanRenderSchedulerRelease();
return;
}
try {
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release status failed", e);
}
}
function vchanStartSchedulerReleasePolling() {
if (schedulerReleasePollTimer) {
clearInterval(schedulerReleasePollTimer);
}
schedulerReleasePollTimer = setInterval(() => {
void vchanPollSchedulerRelease();
}, 1e4);
}
async function vchanToggleSchedulerRelease() {
if (!vchanSessionId) return;
const rigId = vchanRigId || hostState.lastActiveRigId || null;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler release toggle failed", e);
}
}
async function vchanTakeSchedulerControl() {
if (!vchanSessionId) return;
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
try {
const resp = await fetch("/scheduler-control", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, released: false })
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
schedulerReleaseState = await resp.json();
vchanRenderSchedulerRelease();
} catch (e) {
console.error("scheduler control takeover failed", e);
}
}
function vchanHandleSession(data) {
try {
const d = JSON.parse(data);
vchanSessionId = d.session_id || null;
void vchanPollSchedulerRelease();
} catch (e) {
console.warn("vchan: bad session event", e);
}
}
function vchanHandleChannels(data) {
try {
const d = JSON.parse(data);
vchanRigId = d.remote || null;
vchanChannels = d.channels || [];
const ids = new Set(vchanChannels.map((c) => c.id));
const primaryChannel = vchanChannels[0];
if (!vchanActiveId && primaryChannel && vchanSessionId) {
void vchanAutoJoinPrimary(primaryChannel.id);
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
vchanActiveId = vchanChannels[0]?.id ?? null;
vchanReconnectAudio();
}
vchanRender();
vchanRenderSchedulerRelease();
vchanWindow.renderRdsOverlays?.();
} catch (e) {
console.warn("vchan: bad channels event", e);
}
}
function vchanRender() {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.innerHTML = "";
vchanChannels.forEach((ch) => {
const btn = document.createElement("button");
btn.type = "button";
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
if (ch.id === vchanActiveId) btn.classList.add("active");
const label = document.createElement("span");
label.className = "vchan-label";
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
btn.appendChild(label);
if (!ch.permanent) {
const del = document.createElement("span");
del.className = "vchan-del";
del.textContent = "×";
del.title = "Delete channel";
del.addEventListener("click", (e) => {
e.stopPropagation();
void vchanDelete(ch.id);
});
btn.appendChild(del);
}
btn.addEventListener("click", () => {
if (ch.id !== vchanActiveId) void vchanSubscribe(ch.id);
});
picker.appendChild(btn);
});
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "vchan-add";
addBtn.textContent = "+";
addBtn.title = "Allocate new virtual channel at current frequency";
addBtn.addEventListener("click", () => {
void vchanAllocate();
});
picker.appendChild(addBtn);
vchanSyncAccentUI();
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
vchanRenderSchedulerRelease();
}
async function vchanAllocate() {
if (!vchanSessionId || !vchanRigId) return;
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 {
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
});
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
console.warn("vchan: allocate failed —", msg);
return;
}
const ch = await resp.json();
vchanActiveId = ch.id;
vchanRender();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: allocate error", e);
}
}
async function vchanDelete(channelId) {
if (!vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
{ method: "DELETE" }
);
if (!resp.ok) {
console.warn("vchan: delete failed", resp.status);
}
} catch (e) {
console.error("vchan: delete error", e);
}
}
async function vchanAutoJoinPrimary(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId })
}
);
if (!resp.ok) {
console.warn("vchan: auto-join primary failed", resp.status);
return;
}
vchanActiveId = channelId;
vchanRender();
} catch (e) {
console.error("vchan: auto-join error", e);
}
}
async function vchanSubscribe(channelId) {
if (!vchanSessionId || !vchanRigId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: vchanSessionId })
}
);
if (!resp.ok) {
console.warn("vchan: subscribe failed", resp.status);
return;
}
vchanActiveId = channelId;
vchanRender();
vchanSyncModeDisplay();
vchanReconnectAudio();
} catch (e) {
console.error("vchan: subscribe error", e);
}
}
function vchanReconnectAudio() {
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
hostState.audioChannelOverride = ch?.id ?? null;
if (!hostState.rxActive) return;
hostCore.stopRxAudio();
setTimeout(() => {
hostCore.startRxAudio();
}, 300);
}
function vchanApplyCapabilities(caps) {
const picker = document.getElementById("vchan-picker");
if (!picker) return;
picker.style.display = caps && caps.filter_controls ? "" : "none";
vchanRenderSchedulerRelease();
}
function vchanIsOnVirtual() {
if (!vchanActiveId || vchanChannels.length === 0) return false;
return vchanActiveId !== vchanChannels[0]?.id;
}
function vchanActiveChannel() {
return vchanChannels.find((c) => c.id === vchanActiveId) || null;
}
function vchanUpdateFreqDisplay() {
const ch = vchanActiveChannel();
if (!ch) return;
const el = document.getElementById("freq");
if (!el) return;
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();
hostCore.syncModePicker();
}
}
const modeUpper = (modeEl.value || "").toUpperCase();
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();
}
hostState.lastModeName = modeUpper;
}
hostCore.updateWfmControls();
hostCore.updateSdrSquelchControlVisibility();
if (vchanWindow.refreshRdsUi) {
vchanWindow.refreshRdsUi();
} else {
hostCore.positionRdsPsOverlay();
}
}
function vchanSyncBwDisplay() {
if (!vchanIsOnVirtual()) return;
const ch = vchanActiveChannel();
if (!ch) return;
const bwEl = document.getElementById("spectrum-bw-input");
if (!bwEl) return;
let bwHz = ch.bandwidth_hz || 0;
if (bwHz === 0) {
bwHz = hostCore.mwDefaultsForMode(ch.mode)[0] || 0;
}
if (bwHz > 0) {
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
hostState.currentBandwidthHz = bwHz;
}
}
function vchanSyncAccentUI() {
const onVirtual = vchanIsOnVirtual();
const freqEl = document.getElementById("freq");
const bwEl = document.getElementById("spectrum-bw-input");
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
if (onVirtual) {
vchanUpdateFreqDisplay();
vchanSyncModeDisplay();
vchanSyncBwDisplay();
} else {
hostCore.refreshFreqDisplay();
}
hostCore.updateDocumentTitle(hostCore.activeChannelRds());
}
function vchanSetChannelFreq(freqHz) {
if (!vchanRigId || !vchanActiveId) return;
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) {
hostCore.showHint(
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
3e3
);
return;
}
}
void vchanTakeSchedulerControl();
void fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
}
).catch((error) => {
console.error("vchan: set freq error", error);
});
}
async function vchanSetChannelBandwidth(bwHz) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
}
);
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
} catch (e) {
console.error("vchan: set bw error", e);
}
}
async function vchanSetChannelMode(mode) {
if (!vchanRigId || !vchanActiveId) return;
try {
await vchanTakeSchedulerControl();
const resp = await fetch(
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode })
}
);
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
} catch (e) {
console.error("vchan: set mode error", e);
}
}
async function vchanInterceptMode(mode) {
if (!vchanIsOnVirtual()) return false;
await vchanSetChannelMode(mode);
return true;
}
async function vchanInterceptBandwidth(bwHz) {
if (!vchanIsOnVirtual()) return false;
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 = {
get channels() {
return vchanChannels;
},
get activeId() {
return vchanActiveId;
},
activeChannel: vchanActiveChannel,
applyCapabilities: vchanApplyCapabilities,
handleSession: vchanHandleSession,
handleChannels: vchanHandleChannels,
isOnVirtual: vchanIsOnVirtual,
interceptMode: vchanInterceptMode,
interceptBandwidth: vchanInterceptBandwidth,
interceptFrequency: vchanInterceptFrequency,
interceptFreqDisplay: vchanInterceptFreqDisplay,
takeSchedulerControl: vchanTakeSchedulerControl,
releaseToScheduler: vchanToggleSchedulerRelease
};
(function initSchedulerReleaseControl() {
const btn = document.getElementById("scheduler-release-btn");
if (btn) {
btn.addEventListener("click", () => {
void vchanToggleSchedulerRelease();
});
}
vchanStartSchedulerReleasePolling();
vchanRenderSchedulerRelease();
})();
@@ -0,0 +1,276 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/vdes.ts
var vdesWindow = window;
var escapeVdesHtml = (input) => hostCore.escapeMapHtml(input);
var vdesStatus = document.getElementById("vdes-status");
var vdesMessagesEl = document.getElementById("vdes-messages");
var vdesFilterInput = document.getElementById("vdes-filter");
var vdesBarOverlay = document.getElementById("vdes-bar-overlay");
var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
var vdesFrameCountEl = document.getElementById("vdes-frame-count");
var vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
var vdesFilterText = "";
var vdesMessageHistory = [];
function currentVdesHistoryRetentionMs() {
return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
}
function pruneVdesMessageHistory() {
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
}
function scheduleVdesUi(key, job) {
if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
vdesWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleVdesHistoryRender() {
scheduleVdesUi("vdes-history", () => {
renderVdesHistory();
});
}
function scheduleVdesBarUpdate() {
scheduleVdesUi("vdes-bar", () => {
updateVdesBar();
});
}
function currentVdesCenterText() {
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
const hz = raw ? Number(raw) : 0;
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`;
}
function vdesAgeText(tsMs) {
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1e3);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function vdesHexPreview(rawBytes) {
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
}
function updateVdesSummary() {
pruneVdesMessageHistory();
if (vdesChannelSummaryEl) {
vdesChannelSummaryEl.textContent = currentVdesCenterText();
}
if (vdesFrameCountEl) {
const count = vdesMessageHistory.length;
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
}
if (vdesLatestSeenEl) {
const latest = vdesMessageHistory[0];
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
}
}
function applyVdesFilterToRow(row) {
if (!vdesFilterText) {
row.style.display = "";
return;
}
const text = row.dataset.filterText || "";
row.style.display = text.includes(vdesFilterText) ? "" : "none";
}
function renderVdesRow(msg) {
const row = document.createElement("div");
row.className = "vdes-message";
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
const title = msg.vessel_name || "VDES Burst";
const label = msg.callsign || "VDES";
const info = msg.destination || "";
const labelText = msg.message_label || "";
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
const fecText = msg.fec_state || "";
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
const previewText = msg.payload_preview || "";
const rawHex = vdesHexPreview(msg.raw_bytes);
row.dataset.filterText = [
title,
label,
labelText,
info,
srcText,
dstText,
sessionText,
asmText,
countText,
ackText,
cqiText,
previewText,
linkText,
syncText,
phaseText,
fecText,
rawHex,
msg.message_type,
msg.bit_len
].filter(Boolean).join(" ").toUpperCase();
row.innerHTML = `<div class="vdes-row-head"><span class="vdes-time">${ts}</span><span class="vdes-call">${escapeVdesHtml(title)}</span><span class="vdes-badge">${escapeVdesHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeVdesHtml(currentVdesCenterText())}</span><span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeVdesHtml(rawHex)}</span></div>`;
applyVdesFilterToRow(row);
return row;
}
function updateVdesBar() {
if (!vdesBarOverlay) return;
updateVdesSummary();
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
if (!isVdes || messages.length === 0) {
vdesBarOverlay.style.display = "none";
vdesBarOverlay.innerHTML = "";
return;
}
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
for (const msg of messages) {
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
const label = escapeVdesHtml(msg.callsign || "VDES");
const title = escapeVdesHtml(msg.vessel_name || "Burst");
const detail = [
`${msg.bit_len || 0} bits`,
msg.message_label ? escapeVdesHtml(msg.message_label) : null,
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
msg.destination ? escapeVdesHtml(msg.destination) : null,
escapeVdesHtml(vdesAgeText(msg._tsMs))
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
}
vdesBarOverlay.innerHTML = html;
vdesBarOverlay.style.display = "flex";
}
vdesWindow.updateVdesBar = updateVdesBar;
vdesWindow.clearVdesBar = function() {
resetVdesHistoryView();
};
function resetVdesHistoryView() {
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
vdesMessageHistory = [];
updateVdesBar();
renderVdesHistory();
}
function renderVdesHistory() {
pruneVdesMessageHistory();
if (!vdesMessagesEl) {
updateVdesSummary();
return;
}
const fragment = document.createDocumentFragment();
for (const message of vdesMessageHistory) {
fragment.appendChild(renderVdesRow(message));
}
vdesMessagesEl.replaceChildren(fragment);
updateVdesSummary();
}
function addVdesMessage(msg) {
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
vdesMessageHistory.unshift(msg);
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
}
function normalizeServerVdesMessage(msg) {
return {
...msg,
rig_id: msg.rig_id || null
};
}
function onServerVdesBatch(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (vdesStatus) vdesStatus.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerVdesMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
plotVdesMessage(next);
normalized.push(next);
}
normalized.reverse();
vdesMessageHistory = normalized.concat(vdesMessageHistory);
pruneVdesMessageHistory();
scheduleVdesBarUpdate();
scheduleVdesHistoryRender();
}
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 hostCore.postPath("/clear_vdes_decode");
resetVdesHistoryView();
} catch (e) {
console.error("VDES history clear failed", e);
}
})();
});
if (vdesFilterInput) {
vdesFilterInput.addEventListener("input", () => {
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
renderVdesHistory();
});
}
function plotVdesMessage(msg) {
if (msg.lat == null || msg.lon == null || !vdesWindow.vdesMapAddPoint) return;
vdesWindow.vdesMapAddPoint(msg);
}
function onServerVdes(msg) {
if (vdesStatus) vdesStatus.textContent = "Receiving";
const next = normalizeServerVdesMessage(msg);
addVdesMessage(next);
plotVdesMessage(next);
}
function pruneVdesHistoryView() {
pruneVdesMessageHistory();
updateVdesBar();
renderVdesHistory();
}
updateVdesSummary();
window.trxPluginRuntime.registerDecoder({
id: "vdes",
onMessage: onServerVdes,
onBatch: onServerVdesBatch,
restore: onServerVdesBatch,
reset: resetVdesHistoryView,
prune: pruneVdesHistoryView,
// Oldest first, so tracks are rebuilt in the order they happened.
syncMap: () => {
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
}
});
@@ -0,0 +1,336 @@
import {
hostCore
} from "./chunk-KL66PICH.js";
// src/plugins/wefax.ts
var wefaxWindow = window;
var wefaxDom = {
status: document.getElementById("wefax-status"),
liveView: document.getElementById("wefax-live-view"),
historyView: document.getElementById("wefax-history-view"),
liveContainer: document.getElementById("wefax-live-container"),
liveInfo: document.getElementById("wefax-live-info"),
liveCanvas: document.getElementById("wefax-live-canvas"),
liveLatest: document.getElementById("wefax-live-latest"),
historyList: document.getElementById("wefax-history-list"),
historyCount: document.getElementById("wefax-history-count"),
filterInput: document.getElementById("wefax-filter"),
sortSelect: document.getElementById("wefax-sort"),
toggleBtn: document.getElementById("wefax-decode-toggle-btn"),
clearBtn: document.getElementById("wefax-clear-btn"),
viewLiveBtn: document.getElementById("wefax-view-live"),
viewHistoryBtn: document.getElementById("wefax-view-history")
};
var wefaxImageHistory = [];
var WEFAX_MAX_IMAGES = 100;
var wefaxLiveCtx = null;
var wefaxLiveLineCount = 0;
var wefaxLivePixelsPerLine = 1809;
var wefaxActiveView = "live";
var wefaxFilterText = "";
function currentWefaxHistoryRetentionMs() {
return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
}
function pruneWefaxHistory() {
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
wefaxImageHistory = wefaxImageHistory.filter(function(m) {
return (m._tsMs || 0) > cutoff;
});
}
function escapeHtml(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function scheduleWefaxUi(key, job) {
if (typeof wefaxWindow.trxScheduleUiFrameJob === "function") {
wefaxWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function switchWefaxView(view) {
wefaxActiveView = view;
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === "live" ? "" : "none";
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === "history" ? "" : "none";
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function(btn) {
if (btn) btn.classList.remove("sat-view-active");
});
if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active");
if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active");
if (view === "history") renderWefaxHistoryTable();
}
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() {
switchWefaxView("live");
});
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() {
switchWefaxView("history");
});
function resetLiveCanvas(pixelsPerLine) {
const canvas = wefaxDom.liveCanvas;
if (!canvas) return;
wefaxLivePixelsPerLine = pixelsPerLine;
wefaxLiveLineCount = 0;
canvas.width = pixelsPerLine;
canvas.height = 800;
wefaxLiveCtx = canvas.getContext("2d");
if (!wefaxLiveCtx) return;
wefaxLiveCtx.fillStyle = "#000";
wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height);
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "";
}
function paintLine(lineBytes) {
const canvas = wefaxDom.liveCanvas;
if (!wefaxLiveCtx || !canvas) return;
const y = wefaxLiveLineCount;
if (y >= canvas.height) {
const old = wefaxLiveCtx.getImageData(0, 0, canvas.width, canvas.height);
canvas.height *= 2;
wefaxLiveCtx = canvas.getContext("2d");
if (!wefaxLiveCtx) return;
wefaxLiveCtx.putImageData(old, 0, 0);
}
const w = wefaxLivePixelsPerLine;
const imgData = wefaxLiveCtx.createImageData(w, 1);
const d = imgData.data;
for (let x = 0; x < w; x++) {
const v = lineBytes[x] ?? 0;
const i = x * 4;
d[i] = v;
d[i + 1] = v;
d[i + 2] = v;
d[i + 3] = 255;
}
wefaxLiveCtx.putImageData(imgData, 0, y);
wefaxLiveLineCount++;
}
function renderWefaxLatestCard() {
if (!wefaxDom.liveLatest) return;
if (wefaxImageHistory.length === 0) {
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
return;
}
const img = wefaxImageHistory[0];
if (!img) return;
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
const meta = [
`${String(img.ioc ?? "--")} IOC`,
`${String(img.lpm ?? "--")} LPM`,
`${String(img.line_count ?? 0)} lines`,
`${date} ${ts}`
].join(" · ");
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
let html = '<div class="sat-latest-card">';
html += '<div class="sat-latest-title">Latest decoded image</div>';
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + "</div>";
if (imgSrc) {
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
}
html += "</div>";
wefaxDom.liveLatest.innerHTML = html;
}
function getWefaxFilteredHistory() {
let items = wefaxImageHistory;
if (wefaxFilterText) {
items = items.filter(function(i) {
const haystack = [
String(i.ioc || ""),
String(i.lpm || ""),
String(i.line_count || "")
].join(" ").toUpperCase();
return haystack.indexOf(wefaxFilterText) >= 0;
});
}
const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest";
if (sortVal === "oldest") items = items.slice().reverse();
return items;
}
function renderWefaxHistoryRow(img) {
const row = document.createElement("div");
row.className = "sat-history-row";
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
const ioc = img.ioc || "--";
const lpm = img.lpm || "--";
const lines = img.line_count || 0;
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
const link = imgSrc ? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>' : "--";
row.innerHTML = [
"<span>" + escapeHtml(date + " " + ts) + "</span>",
"<span>" + escapeHtml(String(ioc)) + "</span>",
"<span>" + escapeHtml(String(lpm)) + "</span>",
`<span>${String(lines)}</span>`,
"<span>" + link + "</span>"
].join("");
return row;
}
function renderWefaxHistoryTable() {
if (!wefaxDom.historyList) return;
pruneWefaxHistory();
const items = getWefaxFilteredHistory();
const fragment = document.createDocumentFragment();
for (const item of items) {
fragment.appendChild(renderWefaxHistoryRow(item));
}
wefaxDom.historyList.replaceChildren(fragment);
if (wefaxDom.historyCount) {
const total = wefaxImageHistory.length;
const shown = items.length;
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
}
}
function addWefaxImage(msg) {
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
const canvas = wefaxDom.liveCanvas;
if (wefaxLiveCtx && canvas && wefaxLiveLineCount > 0) {
const trimmed = wefaxLiveCtx.getImageData(0, 0, canvas.width, wefaxLiveLineCount);
canvas.height = wefaxLiveLineCount;
wefaxLiveCtx = canvas.getContext("2d");
if (!wefaxLiveCtx) return;
wefaxLiveCtx.putImageData(trimmed, 0, 0);
try {
msg._dataUrl = canvas.toDataURL("image/png");
} catch {
}
}
wefaxImageHistory.unshift(msg);
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
}
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
if (wefaxActiveView === "history") {
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
}
}
function onServerWefaxProgress(msg) {
if (msg.state && !msg.line_data) {
if (wefaxDom.status) {
wefaxDom.status.textContent = msg.state;
wefaxDom.status.style.color = msg.state.indexOf("Idle") === 0 ? "" : "var(--text-accent)";
}
return;
}
if ((msg.line_count ?? 0) <= 1 || !wefaxLiveCtx) {
resetLiveCanvas(msg.pixels_per_line || 1809);
}
if (msg.line_data) {
const binary = atob(msg.line_data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
paintLine(bytes);
}
if (wefaxDom.liveInfo) {
wefaxDom.liveInfo.textContent = `Line ${String(msg.line_count ?? 0)} · ${String(msg.ioc ?? "--")} IOC · ${String(msg.lpm ?? "--")} LPM`;
}
if (wefaxDom.status) {
wefaxDom.status.textContent = `Receiving — line ${String(msg.line_count ?? 0)}`;
wefaxDom.status.style.color = "var(--text-accent)";
}
}
function onServerWefax(msg) {
addWefaxImage(msg);
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
if (wefaxDom.status) {
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
wefaxDom.status.style.color = "";
}
}
function restoreWefaxHistory(messages) {
if (!messages.length) return;
for (const message of messages) {
const tsMs = Number.isFinite(message.ts_ms) ? Number(message.ts_ms) : Date.now();
message._tsMs = tsMs;
message._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
wefaxImageHistory = messages.concat(wefaxImageHistory);
pruneWefaxHistory();
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
if (wefaxActiveView === "history") {
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
}
}
function pruneWefaxHistoryView() {
pruneWefaxHistory();
renderWefaxHistoryTable();
renderWefaxLatestCard();
}
function resetWefaxHistoryView() {
wefaxImageHistory = [];
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = "";
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
wefaxLiveCtx = null;
wefaxLiveLineCount = 0;
renderWefaxLatestCard();
renderWefaxHistoryTable();
if (wefaxDom.status) {
wefaxDom.status.textContent = "Idle";
wefaxDom.status.style.color = "";
}
}
if (wefaxDom.filterInput) {
const filterInput = wefaxDom.filterInput;
wefaxDom.filterInput.addEventListener("input", function() {
wefaxFilterText = filterInput.value.trim().toUpperCase();
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
});
}
if (wefaxDom.sortSelect) {
wefaxDom.sortSelect.addEventListener("change", function() {
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
});
}
wefaxWindow.syncWefaxToggle = function(enabled) {
if (!wefaxDom.toggleBtn) return;
wefaxDom.toggleBtn.dataset.enabled = enabled ? "true" : "false";
wefaxDom.toggleBtn.textContent = enabled ? "Disable WEFAX" : "Enable WEFAX";
wefaxDom.toggleBtn.style.borderColor = enabled ? "#00d17f" : "";
wefaxDom.toggleBtn.style.color = enabled ? "#00d17f" : "";
};
if (wefaxDom.toggleBtn) {
const toggleButton = wefaxDom.toggleBtn;
wefaxDom.toggleBtn.addEventListener("click", () => {
void (async () => {
try {
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
}
await hostCore.postPath("/toggle_wefax_decode");
} catch (e) {
console.error("WEFAX toggle failed", e);
}
})();
});
}
if (wefaxDom.clearBtn) {
wefaxDom.clearBtn.addEventListener("click", () => {
void (async () => {
try {
await hostCore.postPath("/clear_wefax_decode");
resetWefaxHistoryView();
} catch (e) {
console.error("WEFAX clear failed", e);
}
})();
});
}
renderWefaxLatestCard();
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax",
onMessage: onServerWefax,
restore: restoreWefaxHistory,
prune: pruneWefaxHistoryView,
reset: resetWefaxHistoryView
});
wefaxWindow.trxPluginRuntime.registerDecoder({
id: "wefax_progress",
onMessage: onServerWefaxProgress
});
@@ -1,147 +1,131 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space> import {
// hostCore
// SPDX-License-Identifier: GPL-2.0-or-later } from "./chunk-KL66PICH.js";
// --- WSPR Decoder Plugin (server-side decode) --- // src/plugins/wspr.ts
const wsprStatus = document.getElementById("wspr-status"); var wsprWindow = window;
const wsprPeriodEl = document.getElementById("wspr-period"); var wsprStatus = document.getElementById("wspr-status");
const wsprMessagesEl = document.getElementById("wspr-messages"); var wsprPeriodEl = document.getElementById("wspr-period");
const wsprFilterInput = document.getElementById("wspr-filter"); var wsprMessagesEl = document.getElementById("wspr-messages");
const WSPR_PERIOD_SECONDS = 120; var wsprFilterInput = document.getElementById("wspr-filter");
let wsprFilterText = ""; var WSPR_PERIOD_SECONDS = 120;
let wsprMessageHistory = []; var wsprFilterText = "";
var wsprMessageHistory = [];
function finiteNumber(value) {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null;
}
function currentWsprHistoryRetentionMs() { function currentWsprHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function" return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
} }
function pruneWsprMessageHistory() { function pruneWsprMessageHistory() {
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs);
} }
function scheduleWsprHistoryRender() { function scheduleWsprHistoryRender() {
if (typeof window.trxScheduleUiFrameJob === "function") { if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory()); wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
renderWsprHistory();
});
return; return;
} }
renderWsprHistory(); renderWsprHistory();
} }
function fmtWsprTime(tsMs) { function fmtWsprTime(tsMs) {
if (!tsMs) return "--:--:--"; if (!tsMs) return "--:--:--";
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} }
function updateWsprPeriodTimer() { function updateWsprPeriodTimer() {
if (!wsprPeriodEl) return; if (!wsprPeriodEl) return;
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1e3);
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS); const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
const ss = String(remaining % 60).padStart(2, "0"); const ss = String(remaining % 60).padStart(2, "0");
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
} }
updateWsprPeriodTimer(); updateWsprPeriodTimer();
setInterval(updateWsprPeriodTimer, 500); setInterval(updateWsprPeriodTimer, 500);
function renderWsprRow(msg) { function renderWsprRow(msg) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "ft8-row"; row.className = "ft8-row";
row.dataset.decoder = "wspr"; row.dataset.decoder = "wspr";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; const snr = finiteNumber(msg.snr_db);
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; const delta = finiteNumber(msg.dt_s);
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null; const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null; const offsetHz = finiteNumber(msg.freq_hz);
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--"; const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null;
const message = (msg.message || "").toString(); const freq = rfHz?.toFixed(0) ?? "--";
const message = msg.message ?? "";
row.dataset.message = message.toUpperCase(); row.dataset.message = message.toUpperCase();
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`; row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
applyWsprFilterToRow(row); applyWsprFilterToRow(row);
return row; return row;
} }
function renderWsprHistory() { function renderWsprHistory() {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
if (!wsprMessagesEl) return; if (!wsprMessagesEl) return;
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < wsprMessageHistory.length; i += 1) { for (let i = 0; i < wsprMessageHistory.length; i += 1) {
fragment.appendChild(renderWsprRow(wsprMessageHistory[i])); const message = wsprMessageHistory[i];
if (message) fragment.appendChild(renderWsprRow(message));
} }
wsprMessagesEl.replaceChildren(fragment); wsprMessagesEl.replaceChildren(fragment);
} }
function addWsprMessage(msg) { function addWsprMessage(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
wsprMessageHistory.unshift(msg); wsprMessageHistory.unshift(msg);
pruneWsprMessageHistory(); pruneWsprMessageHistory();
scheduleWsprHistoryRender(); scheduleWsprHistoryRender();
} }
function normalizeServerWsprMessage(msg) { function normalizeServerWsprMessage(msg) {
const raw = (msg.message || "").toString(); const raw = msg.message ?? "";
const grids = extractAllGrids(raw); const grids = extractAllGrids(raw);
const station = extractLikelyCallsign(raw); const station = extractLikelyCallsign(raw);
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) const offsetHz = finiteNumber(msg.freq_hz);
? (baseHz + Number(msg.freq_hz)) const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz;
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
return { return {
raw, raw,
grids, grids,
station, station,
rfHz, rfHz,
history: { history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, ts_ms: msg.ts_ms,
snr_db: msg.snr_db, snr_db: msg.snr_db,
dt_s: msg.dt_s, dt_s: msg.dt_s,
freq_hz: msg.freq_hz, freq_hz: msg.freq_hz,
message: raw, message: raw
}, }
}; };
} }
function onServerWsprBatch(messages) {
window.onServerWsprBatch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
wsprStatus.textContent = "Receiving"; if (wsprStatus) wsprStatus.textContent = "Receiving";
const normalized = []; const normalized = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && window.mapAddLocator) { if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg, ...msg,
freq_hz: next.rfHz, ...next.rfHz === null ? {} : { freq_hz: next.rfHz }
}); });
} }
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history); normalized.push(next.history);
} }
normalized.reverse(); normalized.reverse();
wsprMessageHistory = normalized.concat(wsprMessageHistory); wsprMessageHistory = normalized.concat(wsprMessageHistory);
pruneWsprMessageHistory(); pruneWsprMessageHistory();
scheduleWsprHistoryRender(); scheduleWsprHistoryRender();
}; }
function pruneWsprHistoryView() {
window.restoreWsprHistory = function(messages) {
window.onServerWsprBatch(messages);
};
window.pruneWsprHistoryView = function() {
pruneWsprMessageHistory(); pruneWsprMessageHistory();
renderWsprHistory(); renderWsprHistory();
};
function escapeWsprHtml(input) {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
} }
function escapeWsprHtml(input) {
return input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function renderWsprMessage(message) { function renderWsprMessage(message) {
let out = ""; let out = "";
let i = 0; let i = 0;
@@ -159,16 +143,15 @@ function renderWsprMessage(message) {
} }
i = j; i = j;
} else { } else {
out += escapeWsprHtml(ch); out += escapeWsprHtml(ch ?? "");
i += 1; i += 1;
} }
} }
return out; return out;
} }
function extractAllGrids(message) { function extractAllGrids(message) {
const out = []; const out = [];
const seen = new Set(); const seen = /* @__PURE__ */ new Set();
const parts = message.toUpperCase().split(/[^A-Z0-9]+/); const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
for (const token of parts) { for (const token of parts) {
if (!token) continue; if (!token) continue;
@@ -179,9 +162,8 @@ function extractAllGrids(message) {
} }
return out; return out;
} }
function extractLikelyCallsign(message) { function extractLikelyCallsign(message) {
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/); const parts = message.toUpperCase().split(/[^A-Z0-9/]+/);
for (const token of parts) { for (const token of parts) {
if (!token) continue; if (!token) continue;
if (token.length < 3 || token.length > 12) continue; if (token.length < 3 || token.length > 12) continue;
@@ -191,32 +173,28 @@ function extractLikelyCallsign(message) {
} }
return null; return null;
} }
function isFtxFarewellToken(token) { function isFtxFarewellToken(token) {
const normalized = String(token || "").trim().toUpperCase(); const normalized = token.trim().toUpperCase();
return normalized === "RR73" || normalized === "73" || normalized === "RR"; return normalized === "RR73" || normalized === "73" || normalized === "RR";
} }
function isMaidenheadGridToken(token) { function isMaidenheadGridToken(token) {
const normalized = String(token || "").trim().toUpperCase(); const normalized = token.trim().toUpperCase();
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized); return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
} }
function isAlphaNum(ch) { function isAlphaNum(ch) {
return /[A-Za-z0-9]/.test(ch); return ch !== void 0 && /[A-Za-z0-9]/.test(ch);
} }
function activateWsprHistoryLocator(target) {
function activateWsprHistoryLocator(targetEl) { if (!(target instanceof Element)) return false;
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); const locatorEl = target.closest(".ft8-locator[data-locator-grid]");
if (!locatorEl) return false; if (!locatorEl) return false;
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase();
if (!grid) return false; if (!grid) return false;
if (typeof window.navigateToMapLocator === "function") { if (typeof wsprWindow.navigateToMapLocator === "function") {
window.navigateToMapLocator(grid, "wspr"); wsprWindow.navigateToMapLocator(grid, "wspr");
} }
return true; return true;
} }
function applyWsprFilterToRow(row) { function applyWsprFilterToRow(row) {
if (!wsprFilterText) { if (!wsprFilterText) {
row.style.display = ""; row.style.display = "";
@@ -225,26 +203,18 @@ function applyWsprFilterToRow(row) {
const message = row.dataset.message || ""; const message = row.dataset.message || "";
row.style.display = message.includes(wsprFilterText) ? "" : "none"; row.style.display = message.includes(wsprFilterText) ? "" : "none";
} }
function resetWsprHistoryView() {
function applyWsprFilterToAll() { if (wsprMessagesEl) wsprMessagesEl.innerHTML = "";
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => applyWsprFilterToRow(row));
}
window.resetWsprHistoryView = function() {
wsprMessagesEl.innerHTML = "";
wsprMessageHistory = []; wsprMessageHistory = [];
renderWsprHistory(); renderWsprHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr"); if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr");
}; }
if (wsprFilterInput) { if (wsprFilterInput) {
wsprFilterInput.addEventListener("input", () => { wsprFilterInput.addEventListener("input", () => {
wsprFilterText = wsprFilterInput.value.trim().toUpperCase(); wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
renderWsprHistory(); renderWsprHistory();
}); });
} }
if (wsprMessagesEl) { if (wsprMessagesEl) {
wsprMessagesEl.addEventListener("click", (event) => { wsprMessagesEl.addEventListener("click", (event) => {
if (!activateWsprHistoryLocator(event.target)) return; if (!activateWsprHistoryLocator(event.target)) return;
@@ -258,35 +228,44 @@ if (wsprMessagesEl) {
event.stopPropagation(); event.stopPropagation();
}); });
} }
var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); wsprDecodeToggleBtn?.addEventListener("click", () => {
wsprDecodeToggleBtn?.addEventListener("click", async () => { void (async () => {
try { try {
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
await postPath("/toggle_wspr_decode"); await hostCore.postPath("/toggle_wspr_decode");
} catch (e) { } catch (error) {
console.error("WSPR toggle failed", e); console.error("WSPR toggle failed", error);
} }
})();
}); });
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => {
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => { void (async () => {
if (!confirm("Clear all WSPR decode history? This cannot be undone.")) return; if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await postPath("/clear_wspr_decode"); await hostCore.postPath("/clear_wspr_decode");
window.resetWsprHistoryView(); resetWsprHistoryView();
} catch (e) { } catch (error) {
console.error("WSPR history clear failed", e); console.error("WSPR history clear failed", error);
} }
})();
}); });
function onServerWspr(msg) {
window.onServerWspr = function(msg) { if (wsprStatus) wsprStatus.textContent = "Receiving";
wsprStatus.textContent = "Receiving";
const next = normalizeServerWsprMessage(msg); const next = normalizeServerWsprMessage(msg);
if (next.grids.length > 0 && window.mapAddLocator) { if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
...msg, ...msg,
freq_hz: next.rfHz, ...next.rfHz === null ? {} : { freq_hz: next.rfHz }
}); });
} }
addWsprMessage(next.history); addWsprMessage(next.history);
}; }
wsprWindow.trxPluginRuntime.registerDecoder({
id: "wspr",
onMessage: onServerWspr,
onBatch: onServerWsprBatch,
restore: onServerWsprBatch,
prune: pruneWsprHistoryView,
reset: resetWsprHistoryView
});
@@ -22,7 +22,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<svg xmlns="http://www.w3.org/2000/svg" style="display:none"> <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-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-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-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-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> <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>
@@ -43,17 +43,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="subtitle" id="location-subtitle" style="display:none;"></div> <div class="subtitle" id="location-subtitle" style="display:none;"></div>
</div> </div>
</div> </div>
<div class="tab-bar-nav"> <div class="tab-bar-nav" aria-label="Primary navigation">
<button class="tab active" data-tab="main"> <button class="tab active" data-tab="main">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-home"/></svg> <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>
<button class="tab" data-tab="bookmarks"> <button class="tab" data-tab="bookmarks">
<svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg> <svg class="tab-icon" aria-hidden="true"><use href="#icon-bookmark"/></svg>
<span class="tab-label">Bookmarks</span> <span class="tab-label">Bookmarks</span>
</button> </button>
<button class="tab" data-tab="digital-modes"> <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> <span class="tab-label">Digital modes</span>
</button> </button>
<button class="tab" data-tab="map"> <button class="tab" data-tab="map">
@@ -140,13 +140,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="spectrum-wrap"> <div class="spectrum-wrap">
<div id="spectrum-bookmark-axis"></div> <div id="spectrum-bookmark-axis"></div>
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div> <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"></canvas> <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-zoom-indicator" aria-hidden="true"></div>
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div> <div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
<div id="spectrum-db-axis" aria-hidden="true"></div> <div id="spectrum-db-axis" aria-hidden="true"></div>
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div> <div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
<div id="spectrum-tooltip"></div> <div id="spectrum-tooltip"></div>
<canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" style="display:none;"></canvas> <canvas id="spectrum-waterfall-canvas" tabindex="0" role="img" aria-label="Waterfall display" aria-describedby="spectrum-text-summary" style="display:none;"></canvas>
<div id="spectrum-freq-axis"> <div id="spectrum-freq-axis">
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">&lsaquo;</button> <button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">&lsaquo;</button>
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button> <button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button>
@@ -204,27 +209,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="label"><span>Signal strength</span></div> <div class="label"><span>Signal strength</span></div>
</div> </div>
<div class="freq-field frequency-col"> <div class="freq-field frequency-col">
<input class="status-input" id="freq" type="text" value="--" /> <input class="status-input" id="freq" type="text" value="--" aria-describedby="freq-label" aria-label="Tuned frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label"><span>Frequency</span></div> <div class="label" id="freq-label"><span>Frequency</span></div>
</div> </div>
<div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;"> <div class="freq-field frequency-col center-frequency-col" id="center-freq-field" style="display:none;">
<input class="status-input" id="center-freq" type="text" value="--" /> <input class="status-input" id="center-freq" type="text" value="--" aria-describedby="center-freq-label" aria-label="SDR center frequency" autocomplete="off" autocorrect="off" spellcheck="false" />
<div class="label"><span>Center Frequency</span></div> <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>
</div> </div>
@@ -232,10 +222,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="controls-tray-scroll"> <div class="controls-tray-scroll">
<div class="controls-tray"> <div class="controls-tray">
<div class="controls-row full-row"> <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="label"><span>Mode</span></div>
<div class="inline"> <div class="inline">
<select class="status-input" id="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> </div>
<div class="controls-col controls-col-center"> <div class="controls-col controls-col-center">
@@ -247,6 +241,35 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="jog-up" type="button" class="jog-btn">+</button> <button id="jog-up" type="button" class="jog-btn">+</button>
</div> </div>
</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="controls-col controls-col-wfm label-below-col" id="wfm-controls-col" style="display:none;">
<div class="inline wfm-controls-inline"> <div class="inline wfm-controls-inline">
<label class="wfm-control"> <label class="wfm-control">
@@ -304,14 +327,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
<div class="label"><span>SAM</span></div> <div class="label"><span>SAM</span></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">Toggle PTT</button>
<button id="power-btn" type="button">Toggle Power</button>
<button id="lock-btn" type="button">Lock</button>
</div>
</div>
</div> </div>
<div class="full-row label-below-row" id="vfo-row"> <div class="full-row label-below-row" id="vfo-row">
<div class="label"><span>VFO</span></div> <div class="label"><span>VFO</span></div>
@@ -356,30 +371,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<div class="full-row label-below-row" id="vchan-row"> <div class="full-row label-below-row" id="vchan-row">
<div class="label"><span>Channels / Scheduler</span></div> <div class="label"><span>Channels</span></div>
<div class="channel-scheduler-controls"> <div class="channel-scheduler-controls">
<div class="vchan-picker" id="vchan-picker"></div> <div class="vchan-picker" id="vchan-picker"></div>
<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> </div>
</div> </div>
<div class="full-row label-below-row"> <div class="full-row label-below-row">
@@ -412,20 +406,62 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="tx-limit-btn" type="button">Set</button> <button id="tx-limit-btn" type="button">Set</button>
</div> </div>
</div> </div>
<div class="full-row label-below-row" id="audio-row"> <details id="audio-controls" class="advanced-radio-controls audio-controls-section">
<div class="label"><span>Audio</span></div> <summary>Audio controls</summary>
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;"> <div class="advanced-radio-body">
<button id="rx-audio-btn" type="button">Play Audio</button> <div class="full-row" id="audio-row">
<button id="tx-audio-btn" type="button">Transmit Audio</button> <div class="inline" style="gap: 0.6rem; flex-wrap: wrap; align-items: center;">
<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> <button id="rx-audio-btn" type="button">Play Audio</button>
<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> <button id="tx-audio-btn" type="button">Transmit Audio</button>
<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> <span class="audio-group audio-volume-group">
<div id="audio-level"> <span class="audio-group-label">Volume</span>
<div id="audio-level-fill"></div> <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>
<small id="audio-status" style="min-width: 60px;">Off</small>
</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>
<div id="scheduler-release-status" class="scheduler-release-status">Scheduler is controlling the rig.</div>
</div>
</div>
</div>
</details>
</div> </div>
</div> </div>
</div> </div>
@@ -495,6 +531,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</label> </label>
</div> </div>
<div class="bm-form-actions"> <div class="bm-form-actions">
<div id="bm-form-error" class="form-error" role="alert" aria-live="polite"></div>
<button type="submit" class="bm-save-btn">Save</button> <button type="submit" class="bm-save-btn">Save</button>
<button type="button" id="bm-form-cancel">Cancel</button> <button type="button" id="bm-form-cancel">Cancel</button>
</div> </div>
@@ -530,7 +567,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<div id="tab-digital-modes" class="tab-panel" style="display:none;"> <div id="tab-digital-modes" class="tab-panel" style="display:none;">
<div class="sub-tab-bar"> <div class="sub-tab-bar" aria-label="Decoder views">
<button class="sub-tab active" data-subtab="overview">Overview</button> <button class="sub-tab active" data-subtab="overview">Overview</button>
<button class="sub-tab" data-subtab="ais">AIS</button> <button class="sub-tab" data-subtab="ais">AIS</button>
<button class="sub-tab" data-subtab="vdes">VDES</button> <button class="sub-tab" data-subtab="vdes">VDES</button>
@@ -643,19 +680,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" /> <input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small> <small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div> </div>
<div class="ais-summary"> <div class="aprs-filter-row">
<div class="ais-summary-card"> <span class="aprs-counts">
<span class="ais-summary-label">Channels</span> <span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span> <span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
</div> <span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
<div class="ais-summary-card"> </span>
<span class="ais-summary-label">Tracked</span>
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
</div>
<div class="ais-summary-card">
<span class="ais-summary-label">Latest</span>
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
</div>
</div> </div>
<div id="ais-messages"></div> <div id="ais-messages"></div>
</div> </div>
@@ -685,20 +715,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" /> <input id="aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small> <small id="aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div> </div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row"> <div class="aprs-filter-row">
<button id="aprs-type-all" class="aprs-chip active" type="button">All</button> <button id="aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="aprs-type-position" class="aprs-chip" type="button">Pos</button> <button id="aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -706,11 +722,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button> <button id="aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button> <button id="aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="aprs-type-other" class="aprs-chip" type="button">Other</button> <button id="aprs-type-other" class="aprs-chip" type="button">Other</button>
</div> <span class="aprs-filter-sep" aria-hidden="true"></span>
<div class="aprs-filter-row">
<button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button> <button id="aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button> <button id="aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button> <button id="aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div> </div>
<div id="aprs-packets"></div> <div id="aprs-packets"></div>
</div> </div>
@@ -720,20 +740,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" /> <input id="hf-aprs-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. SP2, beacon)" />
<small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small> <small id="hf-aprs-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div> </div>
<div class="aprs-summary">
<div class="aprs-summary-card">
<span class="aprs-summary-label">Frames</span>
<span id="hf-aprs-total-count" class="aprs-summary-value">0 total</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Visible</span>
<span id="hf-aprs-visible-count" class="aprs-summary-value">0 shown</span>
</div>
<div class="aprs-summary-card">
<span class="aprs-summary-label">Latest</span>
<span id="hf-aprs-latest-seen" class="aprs-summary-value">No packets yet</span>
</div>
</div>
<div class="aprs-filter-row"> <div class="aprs-filter-row">
<button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button> <button id="hf-aprs-type-all" class="aprs-chip active" type="button">All</button>
<button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button> <button id="hf-aprs-type-position" class="aprs-chip" type="button">Pos</button>
@@ -741,11 +747,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
<button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button> <button id="hf-aprs-type-weather" class="aprs-chip" type="button">Wx</button>
<button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button> <button id="hf-aprs-type-telemetry" class="aprs-chip" type="button">Tlm</button>
<button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button> <button id="hf-aprs-type-other" class="aprs-chip" type="button">Other</button>
</div> <span class="aprs-filter-sep" aria-hidden="true"></span>
<div class="aprs-filter-row">
<button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button> <button id="hf-aprs-only-pos-btn" class="aprs-chip" type="button">Pos Only</button>
<button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button> <button id="hf-aprs-hide-crc-btn" class="aprs-chip" type="button">No CRC</button>
<button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button> <button id="hf-aprs-collapse-dup-btn" class="aprs-chip" type="button">Dupes</button>
<span class="aprs-counts">
<span id="hf-aprs-visible-count" class="aprs-counts-value">0 shown</span>
<span id="hf-aprs-total-count" class="aprs-counts-value">0 total</span>
<span id="hf-aprs-latest-seen" class="aprs-counts-value">No packets yet</span>
</span>
</div> </div>
<div id="hf-aprs-packets"></div> <div id="hf-aprs-packets"></div>
</div> </div>
@@ -991,48 +1001,52 @@ SPDX-License-Identifier: GPL-2.0-or-later
<template id="tmpl-map"> <template id="tmpl-map">
<div id="map-stage"> <div id="map-stage">
<div class="map-overlay-panel"> <div class="map-overlay-panel">
<div class="map-locator-filter-group"> <div class="map-overlay-filters">
<span class="map-locator-filter-label">Filter by</span> <div class="map-locator-filter-group">
<div id="map-locator-phase" class="map-locator-phase-row"></div> <span class="map-locator-filter-label">Filter</span>
</div> <div id="map-locator-phase" class="map-locator-phase-row"></div>
<div class="map-locator-filter-group"> </div>
<span class="map-locator-filter-label" id="map-locator-choice-label">Show</span> <div class="map-locator-filter-group">
<div id="map-locator-choice-filter" class="map-locator-chip-row"></div> <span class="map-locator-filter-label" id="map-locator-choice-label">Show</span>
</div> <div id="map-locator-choice-filter" class="map-locator-chip-row"></div>
<div class="map-locator-filter-group"> </div>
<span class="map-locator-filter-label">Rig</span> <div class="map-locator-filter-group">
<select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig"> <span class="map-locator-filter-label">Rig</span>
<option value="">All</option> <select id="map-rig-filter" class="map-history-select" aria-label="Filter by rig">
</select> <option value="">All</option>
</div> </select>
<div class="map-locator-filter-group"> </div>
<span class="map-locator-filter-label">Search</span> <div class="map-locator-filter-group">
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." /> <span class="map-locator-filter-label">History</span>
</div> <select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
<div class="map-locator-filter-group"> <option value="15">15 min</option>
<span class="map-locator-filter-label">History</span> <option value="30">30 min</option>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit"> <option value="60">1 hr</option>
<option value="15">15 min</option> <option value="180">3 hrs</option>
<option value="30">30 min</option> <option value="360">6 hrs</option>
<option value="60">1 hr</option> <option value="720">12 hrs</option>
<option value="180">3 hrs</option> <option value="1440">24 hrs</option>
<option value="360">6 hrs</option> </select>
<option value="720">12 hrs</option> </div>
<option value="1440">24 hrs</option> <div class="map-locator-filter-group">
</select> <span class="map-locator-filter-label">Paths</span>
</div> <div class="map-locator-phase-row">
<div class="map-locator-filter-group"> <button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="TRX paths are drawn from a station popup">TRX</button>
<span class="map-locator-filter-label">Paths</span> <button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn" aria-pressed="true" title="Directed decode paths are drawn when the target locator is known">Contact</button>
<div class="map-locator-phase-row"> <span class="map-paths-hint">TRX paths on popup, directed decode paths when target locator is known</span>
<button type="button" id="map-p2p-paths-toggle" class="map-locator-phase-btn">TRX Paths On</button> </div>
<button type="button" id="map-contact-paths-toggle" class="map-locator-phase-btn">Contact Paths On</button> </div>
<span class="map-locator-empty">TRX paths on popup, directed decode paths when target locator is known</span> <!-- Last, so the search field takes whatever the fixed-width
groups leave on the bar's final row rather than a sliver. -->
<div class="map-locator-filter-group map-filter-grow">
<span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div> </div>
</div> </div>
</div> <div class="map-overlay-actions">
<div class="map-corner-controls"> <button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button>
<button type="button" id="map-fullscreen-btn" class="map-fullscreen-btn">Fullscreen</button> <button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button>
<button type="button" id="map-overlay-toggle-btn" class="map-overlay-toggle-btn">Hide Filters</button> </div>
</div> </div>
<div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div> <div id="map-band-legend" class="map-band-legend" aria-label="Band color legend"></div>
<div id="aprs-map"></div> <div id="aprs-map"></div>
@@ -1040,6 +1054,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template> </template>
</div> </div>
<div id="tab-statistics" class="tab-panel" style="display:none;"> <div id="tab-statistics" class="tab-panel" style="display:none;">
<h2 class="section-heading">Statistics</h2>
<template id="tmpl-statistics"> <template id="tmpl-statistics">
<div class="stats-controls"> <div class="stats-controls">
<div class="stats-control-group"> <div class="stats-control-group">
@@ -1180,6 +1195,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</section> </section>
</div> </div>
<div id="tab-settings" class="tab-panel" style="display:none;"> <div id="tab-settings" class="tab-panel" style="display:none;">
<h2 class="section-heading">Settings</h2>
<div class="sub-tab-bar"> <div class="sub-tab-bar">
<button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button> <button class="sub-tab active" data-subtab="settings-scheduler">Scheduler</button>
<button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button> <button class="sub-tab" data-subtab="settings-background-decode">Background Decode</button>
@@ -1475,6 +1491,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</div> </div>
</div> </div>
<div id="tab-about" class="tab-panel" style="display:none;"> <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> <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"> <template id="tmpl-about">
<div class="sub-tab-bar"> <div class="sub-tab-bar">
@@ -1580,10 +1597,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
</template> </template>
</div> </div>
<div class="footer"> <div class="footer">
<div class="copyright"> <div class="footer-meta">
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> <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>
<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>
<div id="conn-lost-overlay" class="decode-history-overlay content-overlay is-hidden" aria-live="assertive" aria-atomic="true"> <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"> <div class="decode-history-overlay-card">
@@ -1617,84 +1637,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
<div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div> <div class="shortcut-overlay-hint">Press <kbd>F1</kbd> or <kbd>Esc</kbd> to close</div>
</div> </div>
</div> </div>
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true"> <div id="decode-history-overlay" class="history-progress is-hidden" role="status" aria-live="polite" aria-atomic="true">
<div class="decode-history-overlay-card"> <div class="history-progress-text">
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div> <span id="decode-history-overlay-title" class="history-progress-title">Loading decode history…</span>
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div> <span id="decode-history-overlay-sub" class="history-progress-sub">Preparing recent decodes for the UI</span>
</div> </div>
<span class="history-progress-track"><span id="decode-history-progress-bar" class="history-progress-bar"></span></span>
</div> </div>
<script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script> <script defer src="/vendor/opus-decoder-0.7.11.min.js" charset="UTF-8"></script>
<script defer src="/vendor/leaflet.js"></script> <script defer src="/vendor/leaflet.js"></script>
<script defer src="/leaflet-ais-tracksymbol.js"></script> <script type="module" src="/app.js"></script>
<script defer src="/webgl-renderer.js"></script> <!-- Template cloning is handled by the typed application bundle. -->
<script defer src="/app.js"></script>
<script>
// Lazy plugin loader: loads plugin scripts when their tab/feature is first activated
(function() {
var pluginScripts = {
'digital-modes': ['/ft8.js', '/ft4.js', '/ft2.js', '/wspr.js', '/cw.js', '/background-decode.js', '/sat.js', '/wefax.js'],
'map-data': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js'],
'map': ['/map-core.js', '/ais.js', '/vdes.js', '/aprs.js', '/hf-aprs.js', '/sat.js', '/sat-scheduler.js'],
'statistics': ['/map-core.js'],
'bookmarks': ['/bookmarks.js'],
'recorder': [],
'settings': ['/vchan.js', '/scheduler.js']
};
var loaded = new Set();
var loading = new Map();
function loadScript(src) {
if (loaded.has(src)) return Promise.resolve();
if (loading.has(src)) return loading.get(src);
var request = new Promise(function(resolve, reject) {
var s = document.createElement('script');
s.src = src;
s.onload = function() {
loaded.add(src);
loading.delete(src);
resolve();
};
s.onerror = function() {
loading.delete(src);
reject(new Error('Failed to load plugin script: ' + src));
};
document.body.appendChild(s);
});
loading.set(src, request);
return request;
}
function loadPlugins(tab) {
var scripts = pluginScripts[tab];
if (!scripts) return Promise.resolve();
return scripts.reduce(function(sequence, src) {
return sequence.then(function() { return loadScript(src); });
}, Promise.resolve());
}
function requestPlugins(tab) {
return loadPlugins(tab).catch(function(err) {
console.error(err);
});
}
// Eager plugin loading is triggered by app.js (after window.trx is set up)
// via window.loadEagerPlugins(). Dynamic scripts are effectively async, so
// loading them before app.js would cause map-core.js to crash when
// window.trx is not yet defined.
window.loadEagerPlugins = function() {
return Promise.all(
['digital-modes', 'map-data', 'bookmarks', 'settings'].map(requestPlugins)
);
};
// Load others on tab switch
document.addEventListener('click', function(e) {
var tab = e.target.closest('[data-tab]');
if (tab) requestPlugins(tab.dataset.tab);
});
window.loadPluginsForTab = loadPlugins;
})();
</script>
<!-- Template cloning is handled by navigateToTab() in app.js -->
</body> </body>
</html> </html>
@@ -1,120 +0,0 @@
(function() {
if (typeof L === "undefined") return;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function finiteAngle(value) {
if (!Number.isFinite(value)) return null;
const normalized = ((Number(value) % 360) + 360) % 360;
return normalized;
}
function svgColor(value, fallback) {
const text = String(value || fallback || "");
return text.replace(/"/g, "&quot;");
}
function buildSymbolHtml(options, zoom) {
const heading = finiteAngle(options.heading);
const course = finiteAngle(options.course);
const angle = heading != null ? heading : course;
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
const color = svgColor(options.color, "#ff7559");
const outline = svgColor(options.outline, "#6b2118");
const body = angle != null
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
`</g>`
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
const courseLine = course != null
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
`</g>`
: "";
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
courseLine +
body +
`</svg>`
);
}
L.TrxAisTrackSymbol = L.Marker.extend({
options: {
heading: null,
course: null,
speed: null,
color: "#ff7559",
outline: "#6b2118",
size: 22,
interactive: true,
keyboard: true,
riseOnHover: true,
},
initialize: function(latlng, options) {
const merged = L.Util.extend({}, this.options, options || {});
merged.icon = L.divIcon({
className: "trx-ais-track-symbol-icon",
html: "",
iconSize: [merged.size, merged.size],
iconAnchor: [merged.size / 2, merged.size / 2],
});
L.Marker.prototype.initialize.call(this, latlng, merged);
},
onAdd: function(map) {
L.Marker.prototype.onAdd.call(this, map);
this._refreshIcon();
this._boundZoomRefresh = this._refreshIcon.bind(this);
map.on("zoomend", this._boundZoomRefresh);
},
onRemove: function(map) {
if (this._boundZoomRefresh) {
map.off("zoomend", this._boundZoomRefresh);
this._boundZoomRefresh = null;
}
L.Marker.prototype.onRemove.call(this, map);
},
setAisState: function(next) {
if (next && typeof next === "object") {
if ("heading" in next) this.options.heading = next.heading;
if ("course" in next) this.options.course = next.course;
if ("speed" in next) this.options.speed = next.speed;
if ("color" in next) this.options.color = next.color;
if ("outline" in next) this.options.outline = next.outline;
}
this._refreshIcon();
return this;
},
_refreshIcon: function() {
if (!this._icon) return;
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
const html = buildSymbolHtml(this.options, zoom);
this._icon.innerHTML = html;
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
this._icon.style.width = `${size}px`;
this._icon.style.height = `${size}px`;
this._icon.style.marginLeft = `${-size / 2}px`;
this._icon.style.marginTop = `${-size / 2}px`;
},
});
L.trxAisTrackSymbol = function(latlng, options) {
return new L.TrxAisTrackSymbol(latlng, options);
};
})();
@@ -1,498 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- APRS Decoder Plugin (server-side decode) ---
const aprsStatus = document.getElementById("aprs-status");
const aprsPacketsEl = document.getElementById("aprs-packets");
const aprsFilterInput = document.getElementById("aprs-filter");
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
const aprsTotalCountEl = document.getElementById("aprs-total-count");
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
let aprsFilterText = "";
let aprsPacketHistory = [];
let aprsBarDismissedAtMs = 0;
let aprsOnlyPos = false;
let aprsHideCrc = false;
let aprsCollapseDup = false;
let aprsTypeFilter = "all";
function currentAprsHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneAprsPacketHistory() {
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
}
function scheduleAprsUi(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAprsHistoryRender() {
scheduleAprsUi("aprs-history", () => renderAprsHistory());
}
function scheduleAprsBarUpdate() {
scheduleAprsUi("aprs-bar", () => updateAprsBar());
}
function renderAprsInfo(pkt) {
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
if (bytes && bytes.length > 0) {
let out = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b >= 0x20 && b <= 0x7e) {
const ch = String.fromCharCode(b);
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = b.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
const str = pkt.info || "";
let out = "";
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code >= 0x20 && code <= 0x7e) {
const ch = str[i];
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = code.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
function aprsPacketCategory(pkt) {
const type = String(pkt.type || "").toLowerCase();
const info = String(pkt.info || "").toLowerCase();
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function aprsCategoryLabel(category) {
switch (category) {
case "position": return "Position";
case "message": return "Message";
case "weather": return "Weather";
case "telemetry": return "Telemetry";
default: return "Other";
}
}
function aprsAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function aprsDistanceText(pkt) {
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = haversineKm(serverLat, 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`;
}
function aprsPacketSignature(pkt) {
return [
pkt.srcCall || "",
pkt.destCall || "",
pkt.path || "",
pkt.info || "",
pkt.type || "",
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
].join("|");
}
function aprsHexBytes(bytes) {
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function aprsFilterMatch(pkt) {
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (aprsHideCrc && !pkt.crcOk) return false;
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
if (!aprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
aprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsVisiblePackets() {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
return packets.filter(aprsFilterMatch);
}
function collapseAprsDuplicates(packets) {
const seen = new Set();
const out = [];
for (const pkt of packets) {
const key = aprsPacketSignature(pkt);
if (seen.has(key)) continue;
seen.add(key);
out.push(pkt);
}
return out;
}
function updateAprsSummary() {
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
}
}
}
function updateAprsChipState() {
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
});
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
function renderAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = aprsAgeText(pkt._tsMs);
const category = aprsPacketCategory(pkt);
const categoryLabel = aprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
let symbolHtml = "";
if (pkt.symbolTable && pkt.symbolCode) {
const sheet = pkt.symbolTable === "/" ? 0 : 1;
const code = pkt.symbolCode.charCodeAt(0) - 33;
const col = code % 16;
const row2 = Math.floor(code / 16);
const bgX = -(col * 24);
const bgY = -(row2 * 24);
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
}
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = aprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
symbolHtml +
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = String(el.dataset.aprsMap || "");
const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const raw = String(copyBtn.dataset.aprsCopy || "");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(raw);
showHint("Coordinates copied", 1200);
}
} catch (_e) {
showHint("Copy failed", 1500);
}
});
}
return row;
}
function renderAprsHistory() {
pruneAprsPacketHistory();
if (!aprsPacketsEl) {
updateAprsSummary();
updateAprsChipState();
return;
}
const visible = aprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (let i = 0; i < visible.length; i++) {
fragment.appendChild(renderAprsRow(visible[i], i === 0));
}
aprsPacketsEl.replaceChildren(fragment);
updateAprsSummary();
updateAprsChipState();
}
function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
return;
}
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>';
for (const pkt of frames) {
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
const dest = escapeMapHtml(pkt.destCall || "");
const info = escapeMapHtml(pkt.info || "");
const pin = pkt.lat != null && pkt.lon != null
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
: "";
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div>` +
`</div>`;
}
aprsBarOverlay.innerHTML = html;
aprsBarOverlay.style.display = "flex";
}
window.updateAprsBar = updateAprsBar;
window.clearAprsBar = function() {
window.resetAprsHistoryView();
};
window.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
}
};
window.resetAprsHistoryView = function() {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
};
window.pruneAprsHistoryView = function() {
pruneAprsPacketHistory();
updateAprsBar();
renderAprsHistory();
};
function addAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
}
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
function normalizeServerAprsPacket(pkt) {
return {
rig_id: pkt.rig_id || null,
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
srcCall: pkt.src_call,
destCall: pkt.dest_call,
path: pkt.path,
info: pkt.info,
info_bytes: pkt.info_bytes,
type: pkt.packet_type,
crcOk: pkt.crc_ok,
ts_ms: pkt.ts_ms,
lat: pkt.lat,
lon: pkt.lon,
symbolTable: pkt.symbol_table,
symbolCode: pkt.symbol_code,
};
}
window.onServerAprsBatch = function(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
aprsStatus.textContent = "Receiving";
const normalized = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
}
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
normalized.reverse();
aprsPacketHistory = normalized.concat(aprsPacketHistory);
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
};
window.restoreAprsHistory = function(packets) {
window.onServerAprsBatch(packets);
};
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
if (!confirm("Clear all APRS decode history? This cannot be undone.")) return;
try {
await postPath("/clear_aprs_decode");
window.resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
}
});
if (aprsOnlyPosBtn) {
aprsOnlyPosBtn.addEventListener("click", () => {
aprsOnlyPos = !aprsOnlyPos;
renderAprsHistory();
});
}
if (aprsHideCrcBtn) {
aprsHideCrcBtn.addEventListener("click", () => {
aprsHideCrc = !aprsHideCrc;
renderAprsHistory();
});
}
if (aprsCollapseDupBtn) {
aprsCollapseDupBtn.addEventListener("click", () => {
aprsCollapseDup = !aprsCollapseDup;
renderAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
aprsTypeFilter = type;
renderAprsHistory();
});
});
if (aprsFilterInput) {
aprsFilterInput.addEventListener("input", () => {
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
renderAprsHistory();
});
}
// --- Server-side APRS decode handler ---
window.onServerAprs = function(pkt) {
aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
};
renderAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
@@ -1,207 +0,0 @@
// --- FT2 Decoder Plugin (server-side decode) ---
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
// SPDX-License-Identifier: GPL-2.0-or-later
function ft8RenderMessageFt2(message) {
if (typeof renderFt8Message === "function") return renderFt8Message(message);
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
return message;
}
const ft2Status = document.getElementById("ft2-status");
const ft2PeriodEl = document.getElementById("ft2-period");
const ft2MessagesEl = document.getElementById("ft2-messages");
const ft2FilterInput = document.getElementById("ft2-filter");
const FT2_PERIOD_MS = 3750;
const FT2_MAX_DOM_ROWS = 200;
let ft2FilterText = "";
let ft2MessageHistory = [];
function currentFt2HistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneFt2MessageHistory() {
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleFt2Ui(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
function normalizeFt2DisplayFreqHz(freqHz) {
const rawHz = Number(freqHz);
if (!Number.isFinite(rawHz)) return null;
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
return baseHz + rawHz;
}
return rawHz;
}
function updateFt2PeriodTimer() {
if (!ft2PeriodEl) return;
const nowMs = Date.now();
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
}
updateFt2PeriodTimer();
setInterval(updateFt2PeriodTimer, 250);
function renderFt2Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.decoder = "ft2";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
const renderedMessage = ft8RenderMessageFt2(rawMessage);
const tsMs = msg._tsMs ?? msg.ts_ms;
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
return row;
}
function renderFt2History() {
pruneFt2MessageHistory();
if (!ft2MessagesEl) return;
const filter = ft2FilterText;
const fragment = document.createDocumentFragment();
let rendered = 0;
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
const msg = ft2MessageHistory[i];
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
fragment.appendChild(renderFt2Row(msg));
rendered++;
}
ft2MessagesEl.replaceChildren(fragment);
}
function addFt2Message(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
ft2MessageHistory.unshift(msg);
pruneFt2MessageHistory();
window.setFt8FamilyBarDecoder?.("ft2");
window.updateFt8Bar?.();
scheduleFt2HistoryRender();
}
function normalizeServerFt2Message(msg) {
const raw = (msg.message || "").toString();
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
const grids = locatorDetails.length > 0
? locatorDetails.map((d) => d.grid)
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
return {
raw, grids, station, rfHz, locatorDetails,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
message: msg.message,
},
};
}
window.onServerFt2Batch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (ft2Status) ft2Status.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerFt2Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
ft2MessageHistory = normalized.concat(ft2MessageHistory);
pruneFt2MessageHistory();
window.setFt8FamilyBarDecoder?.("ft2");
window.updateFt8Bar?.();
scheduleFt2HistoryRender();
};
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
window.resetFt2HistoryView = function() {
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
ft2MessageHistory = [];
window.updateFt8Bar?.();
renderFt2History();
};
function buildFt2BarFrames() {
const cutoffMs = Date.now() - 15 * 60 * 1000;
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
if (messages.length === 0) {
return { count: 0, newestTsMs: 0, html: "" };
}
let html = "";
for (const msg of messages) {
const tsMs = msg._tsMs ?? msg.ts_ms;
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
const text = ft8RenderMessageFt2((msg.message || "").toString());
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: messages.length, newestTsMs, html };
}
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
if (ft2FilterInput) {
ft2FilterInput.addEventListener("input", () => {
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
renderFt2History();
});
}
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
ft2DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
await postPath("/toggle_ft2_decode");
} catch (e) {
console.error("FT2 toggle failed", e);
}
});
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT2 decode history? This cannot be undone.")) return;
try {
await postPath("/clear_ft2_decode");
window.resetFt2HistoryView();
} catch (e) { console.error("FT2 history clear failed", e); }
});
window.onServerFt2 = function(msg) {
if (ft2Status) ft2Status.textContent = "Receiving";
const next = normalizeServerFt2Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
addFt2Message(next.history);
};
@@ -1,207 +0,0 @@
// --- FT4 Decoder Plugin (server-side decode) ---
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
// SPDX-License-Identifier: GPL-2.0-or-later
function ft8RenderMessage(message) {
if (typeof renderFt8Message === "function") return renderFt8Message(message);
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
return message;
}
const ft4Status = document.getElementById("ft4-status");
const ft4PeriodEl = document.getElementById("ft4-period");
const ft4MessagesEl = document.getElementById("ft4-messages");
const ft4FilterInput = document.getElementById("ft4-filter");
const FT4_PERIOD_MS = 7500;
const FT4_MAX_DOM_ROWS = 200;
let ft4FilterText = "";
let ft4MessageHistory = [];
function currentFt4HistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneFt4MessageHistory() {
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleFt4Ui(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
function normalizeFt4DisplayFreqHz(freqHz) {
const rawHz = Number(freqHz);
if (!Number.isFinite(rawHz)) return null;
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
return baseHz + rawHz;
}
return rawHz;
}
function updateFt4PeriodTimer() {
if (!ft4PeriodEl) return;
const nowMs = Date.now();
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
}
updateFt4PeriodTimer();
setInterval(updateFt4PeriodTimer, 250);
function renderFt4Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.decoder = "ft4";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
const renderedMessage = ft8RenderMessage(rawMessage);
const tsMs = msg._tsMs ?? msg.ts_ms;
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
return row;
}
function renderFt4History() {
pruneFt4MessageHistory();
if (!ft4MessagesEl) return;
const filter = ft4FilterText;
const fragment = document.createDocumentFragment();
let rendered = 0;
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
const msg = ft4MessageHistory[i];
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
fragment.appendChild(renderFt4Row(msg));
rendered++;
}
ft4MessagesEl.replaceChildren(fragment);
}
function addFt4Message(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
ft4MessageHistory.unshift(msg);
pruneFt4MessageHistory();
window.setFt8FamilyBarDecoder?.("ft4");
window.updateFt8Bar?.();
scheduleFt4HistoryRender();
}
function normalizeServerFt4Message(msg) {
const raw = (msg.message || "").toString();
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
const grids = locatorDetails.length > 0
? locatorDetails.map((d) => d.grid)
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
return {
raw, grids, station, rfHz, locatorDetails,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
message: msg.message,
},
};
}
window.onServerFt4Batch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
if (ft4Status) ft4Status.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerFt4Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
ft4MessageHistory = normalized.concat(ft4MessageHistory);
pruneFt4MessageHistory();
window.setFt8FamilyBarDecoder?.("ft4");
window.updateFt8Bar?.();
scheduleFt4HistoryRender();
};
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
window.resetFt4HistoryView = function() {
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
ft4MessageHistory = [];
window.updateFt8Bar?.();
renderFt4History();
};
function buildFt4BarFrames() {
const cutoffMs = Date.now() - 15 * 60 * 1000;
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
if (messages.length === 0) {
return { count: 0, newestTsMs: 0, html: "" };
}
let html = "";
for (const msg of messages) {
const tsMs = msg._tsMs ?? msg.ts_ms;
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
const text = ft8RenderMessage((msg.message || "").toString());
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: messages.length, newestTsMs, html };
}
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
if (ft4FilterInput) {
ft4FilterInput.addEventListener("input", () => {
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
renderFt4History();
});
}
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
ft4DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
await postPath("/toggle_ft4_decode");
} catch (e) {
console.error("FT4 toggle failed", e);
}
});
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT4 decode history? This cannot be undone.")) return;
try {
await postPath("/clear_ft4_decode");
window.resetFt4HistoryView();
} catch (e) { console.error("FT4 history clear failed", e); }
});
window.onServerFt4 = function(msg) {
if (ft4Status) ft4Status.textContent = "Receiving";
const next = normalizeServerFt4Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
}
addFt4Message(next.history);
};
@@ -1,486 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- FT8 Decoder Plugin (server-side decode) ---
const ft8Status = document.getElementById("ft8-status");
const ft8PeriodEl = document.getElementById("ft8-period");
const ft8MessagesEl = document.getElementById("ft8-messages");
const ft8FilterInput = document.getElementById("ft8-filter");
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
const FT8_PERIOD_SECONDS = 15;
const FT8_MAX_DOM_ROWS = 200;
const FT8_BAR_DECODER_LABELS = {
ft8: "FT8",
ft4: "FT4",
ft2: "FT2",
};
let ft8FilterText = "";
let ft8MessageHistory = [];
let ft8BarActiveDecoder = "ft8";
const ft8BarBuilders = {};
const ft8BarDismissedAtMsByDecoder = {
ft8: 0,
ft4: 0,
ft2: 0,
};
function currentFt8HistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneFt8MessageHistory() {
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
}
function scheduleFt8Ui(key, job) {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleFt8HistoryRender() {
scheduleFt8Ui("ft8-history", () => renderFt8History());
}
function scheduleFt8BarUpdate() {
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
}
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
ft8BarBuilders[decoder] = builder;
};
window.setFt8FamilyBarDecoder = function(decoder) {
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
ft8BarActiveDecoder = decoder;
scheduleFt8BarUpdate();
};
function normalizeFt8DisplayFreqHz(freqHz) {
const rawHz = Number(freqHz);
if (!Number.isFinite(rawHz)) return null;
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
return baseHz + rawHz;
}
return rawHz;
}
function fmtTime(tsMs) {
if (!tsMs) return "--:--:--";
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function updateFt8PeriodTimer() {
if (!ft8PeriodEl) return;
const nowSec = Math.floor(Date.now() / 1000);
const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
}
updateFt8PeriodTimer();
setInterval(updateFt8PeriodTimer, 500);
function renderFt8Row(msg) {
const row = document.createElement("div");
row.className = "ft8-row";
const rawMessage = (msg.message || "").toString();
row.dataset.message = rawMessage.toUpperCase();
row.dataset.decoder = "ft8";
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
const renderedMessage = renderFt8Message(rawMessage);
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
applyFt8FilterToRow(row);
return row;
}
function renderFt8History() {
pruneFt8MessageHistory();
if (!ft8MessagesEl) return;
const fragment = document.createDocumentFragment();
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
for (let i = 0; i < limit; i += 1) {
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
}
ft8MessagesEl.replaceChildren(fragment);
}
function addFt8Message(msg) {
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
ft8MessageHistory.unshift(msg);
pruneFt8MessageHistory();
ft8BarActiveDecoder = "ft8";
scheduleFt8BarUpdate();
scheduleFt8HistoryRender();
}
function normalizeServerFt8Message(msg) {
const raw = (msg.message || "").toString();
const locatorDetails = ft8ExtractLocatorDetails(raw);
const grids = locatorDetails.length > 0
? locatorDetails.map((detail) => detail.grid)
: ft8ExtractAllGrids(raw);
const station = ft8ExtractLikelyCallsign(raw);
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
return {
raw,
grids,
station,
rfHz,
locatorDetails,
history: {
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
ts_ms: msg.ts_ms,
snr_db: msg.snr_db,
dt_s: msg.dt_s,
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
message: msg.message,
},
};
}
window.onServerFt8Batch = function(messages) {
if (!Array.isArray(messages) || messages.length === 0) return;
ft8Status.textContent = "Receiving";
const normalized = [];
for (const msg of messages) {
const next = normalizeServerFt8Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
...msg,
freq_hz: next.rfHz,
locator_details: next.locatorDetails,
});
}
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
normalized.push(next.history);
}
normalized.reverse();
ft8MessageHistory = normalized.concat(ft8MessageHistory);
pruneFt8MessageHistory();
ft8BarActiveDecoder = "ft8";
scheduleFt8BarUpdate();
scheduleFt8HistoryRender();
};
window.restoreFt8History = function(messages) {
window.onServerFt8Batch(messages);
};
window.pruneFt8HistoryView = function() {
pruneFt8MessageHistory();
updateFt8Bar();
renderFt8History();
};
function ft8BarRfText(msg) {
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
if (!Number.isFinite(displayFreqHz)) return null;
return `${displayFreqHz.toFixed(0)} Hz`;
}
function buildFt8BarFrames() {
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
if (messages.length === 0) {
return { count: 0, newestTsMs: 0, html: "" };
}
let html = "";
for (const msg of messages) {
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
const rf = ft8BarRfText(msg);
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
const text = ft8EscapeHtml((msg.message || "").toString());
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: messages.length, newestTsMs, html };
}
function updateFt8Bar() {
if (!ft8BarOverlay) return;
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
const decoder = ft8BarActiveDecoder;
const builder = ft8BarBuilders[decoder];
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
const result = typeof builder === "function" ? builder() : null;
const newestTsMs = Number(result?.newestTsMs) || 0;
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
ft8BarOverlay.style.display = "none";
ft8BarOverlay.innerHTML = "";
return;
}
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">&times;</button></span></div>${result.html}`;
ft8BarOverlay.style.display = "flex";
}
window.updateFt8Bar = updateFt8Bar;
window.clearFt8Bar = function() {
const decoder = ft8BarActiveDecoder;
if (decoder === "ft4") {
window.resetFt4HistoryView?.();
return;
}
if (decoder === "ft2") {
window.resetFt2HistoryView?.();
return;
}
window.resetFt8HistoryView?.();
};
window.closeFt8Bar = function() {
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
if (ft8BarOverlay) {
ft8BarOverlay.style.display = "none";
ft8BarOverlay.innerHTML = "";
}
};
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
function renderFt8Message(message) {
let out = "";
let i = 0;
while (i < message.length) {
const ch = message[i];
if (ft8IsAlphaNum(ch)) {
let j = i + 1;
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
const token = message.slice(i, j);
const grid = token.toUpperCase();
if (ft8IsMaidenheadGridToken(grid)) {
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
} else {
out += ft8EscapeHtml(token);
}
i = j;
} else {
out += ft8EscapeHtml(ch);
i += 1;
}
}
return out;
}
function ft8TokenizeMessage(message) {
return String(message || "")
.toUpperCase()
.split(/[^A-Z0-9/]+/)
.filter(Boolean);
}
function ft8ExtractAllGrids(message) {
const out = [];
const seen = new Set();
let i = 0;
while (i < message.length) {
if (ft8IsAlphaNum(message[i])) {
let j = i + 1;
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
const token = message.slice(i, j);
const grid = token.toUpperCase();
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
seen.add(grid);
out.push(grid);
}
i = j;
} else {
i += 1;
}
}
return out;
}
function ft8ExtractLocatorDetails(message) {
const tokens = ft8TokenizeMessage(message);
const grids = ft8ExtractAllGrids(String(message || ""));
if (tokens.length === 0 || grids.length === 0) return [];
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
const callsigns = [];
for (let i = 0; i < limit; i += 1) {
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
}
let source = null;
let target = null;
const head = tokens[0];
if (callsigns.length > 0) {
if (head === "CQ" || head === "DE" || head === "QRZ") {
source = callsigns[0];
} else if (callsigns.length >= 2) {
target = callsigns[0];
source = callsigns[1];
} else {
source = callsigns[0];
}
}
return grids.map((grid) => ({
grid,
station: source || null,
source: source || null,
target: target || null,
}));
}
function ft8ExtractLikelyCallsign(message) {
const locatorDetails = ft8ExtractLocatorDetails(message);
if (locatorDetails.length > 0 && locatorDetails[0].station) {
return locatorDetails[0].station;
}
const tokens = ft8TokenizeMessage(message);
for (const token of tokens) {
if (ft8IsLikelyCallsignToken(token)) return token;
}
return null;
}
function ft8IsLikelyCallsignToken(token) {
if (!token) return false;
if (token.length < 3 || token.length > 12) return false;
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
if (ft8IsMaidenheadGridToken(token)) return false;
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
}
function ft8IsFarewellToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return normalized === "RR73" || normalized === "73" || normalized === "RR";
}
function ft8IsMaidenheadGridToken(token) {
const normalized = String(token || "").trim().toUpperCase();
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
}
function ft8EscapeHtml(input) {
return input
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
function ft8IsAlphaNum(ch) {
return /[A-Za-z0-9]/.test(ch);
}
function activateFt8HistoryLocator(targetEl) {
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
if (!locatorEl) return false;
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
if (!grid) return false;
if (typeof window.navigateToMapLocator === "function") {
window.navigateToMapLocator(grid, "ft8");
}
return true;
}
function applyFt8FilterToRow(row) {
if (!ft8FilterText) {
row.style.display = "";
return;
}
const message = row.dataset.message || "";
row.style.display = message.includes(ft8FilterText) ? "" : "none";
}
function applyFt8FilterToAll() {
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => applyFt8FilterToRow(row));
}
function updateFt8RowRf(row) {
const freqEl = row.querySelector(".ft8-freq");
if (!freqEl) return;
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
if (Number.isFinite(displayFreqHz)) {
freqEl.textContent = displayFreqHz.toFixed(0);
} else {
freqEl.textContent = "--";
}
}
window.updateFt8RfDisplay = function() {
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
rows.forEach((row) => updateFt8RowRf(row));
updateFt8Bar();
};
window.resetFt8HistoryView = function() {
ft8MessagesEl.innerHTML = "";
ft8MessageHistory = [];
updateFt8Bar();
renderFt8History();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
};
if (ft8FilterInput) {
ft8FilterInput.addEventListener("input", () => {
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
renderFt8History();
});
}
if (ft8MessagesEl) {
ft8MessagesEl.addEventListener("click", (event) => {
if (!activateFt8HistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
ft8MessagesEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
if (!activateFt8HistoryLocator(event.target)) return;
event.preventDefault();
event.stopPropagation();
});
}
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
ft8DecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
await postPath("/toggle_ft8_decode");
} catch (e) {
console.error("FT8 toggle failed", e);
}
});
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
if (!confirm("Clear all FT8 decode history? This cannot be undone.")) return;
try {
await postPath("/clear_ft8_decode");
window.resetFt8HistoryView();
} catch (e) {
console.error("FT8 history clear failed", e);
}
});
// --- Server-side FT8 decode handler ---
window.onServerFt8 = function(msg) {
ft8Status.textContent = "Receiving";
const next = normalizeServerFt8Message(msg);
if (next.grids.length > 0 && window.mapAddLocator) {
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
...msg,
freq_hz: next.rfHz,
locator_details: next.locatorDetails,
});
}
addFt8Message(next.history);
};
@@ -1,444 +0,0 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
let hfAprsFilterText = "";
let hfAprsPacketHistory = [];
let hfAprsOnlyPos = false;
let hfAprsHideCrc = false;
let hfAprsCollapseDup = false;
let hfAprsTypeFilter = "all";
function currentHfAprsHistoryRetentionMs() {
return typeof window.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneHfAprsPacketHistory() {
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
}
function scheduleHfAprsHistoryRender() {
if (typeof window.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
return;
}
renderHfAprsHistory();
}
function hfAprsPacketCategory(pkt) {
const type = String(pkt.type || "").toLowerCase();
const info = String(pkt.info || "").toLowerCase();
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
function hfAprsCategoryLabel(category) {
switch (category) {
case "position": return "Position";
case "message": return "Message";
case "weather": return "Weather";
case "telemetry": return "Telemetry";
default: return "Other";
}
}
function hfAprsAgeText(tsMs) {
if (!Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
return `${hours}h ago`;
}
function hfAprsDistanceText(pkt) {
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
const distKm = haversineKm(serverLat, 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`;
}
function hfAprsPacketSignature(pkt) {
return [
pkt.srcCall || "",
pkt.destCall || "",
pkt.path || "",
pkt.info || "",
pkt.type || "",
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
].join("|");
}
function hfAprsHexBytes(bytes) {
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
function hfAprsFilterMatch(pkt) {
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (hfAprsHideCrc && !pkt.crcOk) return false;
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
if (!hfAprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
hfAprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsVisiblePackets() {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
function collapseHfAprsDuplicates(packets) {
const seen = new Set();
const out = [];
for (const pkt of packets) {
const key = hfAprsPacketSignature(pkt);
if (seen.has(key)) continue;
seen.add(key);
out.push(pkt);
}
return out;
}
function updateHfAprsSummary() {
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
}
}
}
function updateHfAprsChipState() {
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
});
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
function renderHfAprsInfo(pkt) {
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
if (bytes && bytes.length > 0) {
let out = "";
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b >= 0x20 && b <= 0x7e) {
const ch = String.fromCharCode(b);
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = b.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
const str = pkt.info || "";
let out = "";
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i);
if (code >= 0x20 && code <= 0x7e) {
const ch = str[i];
if (ch === "<") out += "&lt;";
else if (ch === ">") out += "&gt;";
else if (ch === "&") out += "&amp;";
else if (ch === '"') out += "&quot;";
else out += ch;
} else {
const hex = code.toString(16).toUpperCase().padStart(2, "0");
out += `<span class="aprs-byte">0x${hex}</span>`;
}
}
return out;
}
function renderHfAprsRow(pkt, isFresh) {
const row = document.createElement("div");
row.className = "aprs-packet";
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
if (isFresh) row.classList.add("aprs-packet-new");
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const age = hfAprsAgeText(pkt._tsMs);
const category = hfAprsPacketCategory(pkt);
const categoryLabel = hfAprsCategoryLabel(category);
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
let symbolHtml = "";
if (pkt.symbolTable && pkt.symbolCode) {
const sheet = pkt.symbolTable === "/" ? 0 : 1;
const code = pkt.symbolCode.charCodeAt(0) - 33;
const col = code % 16;
const row2 = Math.floor(code / 16);
const bgX = -(col * 24);
const bgY = -(row2 * 24);
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
}
const posLink = pkt.lat != null && pkt.lon != null
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
: "";
const distance = hfAprsDistanceText(pkt);
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
row.innerHTML =
`<div class="aprs-row-head">` +
`<span class="aprs-time">${ts}</span>` +
hfBadge +
symbolHtml +
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
`<span>&gt;${escapeMapHtml(pkt.destCall || "")}</span>` +
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
pathBadge +
crcBadge +
`</div>` +
`<div class="aprs-row-meta">` +
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
`</div>` +
`<div class="aprs-row-detail">` +
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
(posLink ? `<span>${posLink}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`<details class="aprs-details">` +
`<summary>Details</summary>` +
`<div class="aprs-details-grid">` +
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
`</div>` +
`</details>`;
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
const raw = String(el.dataset.aprsMap || "");
const [lat, lon] = raw.split(",").map(Number);
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
window.navigateToAprsMap(lat, lon);
}
});
});
const copyBtn = row.querySelector("[data-aprs-copy]");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const raw = String(copyBtn.dataset.aprsCopy || "");
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(raw);
showHint("Coordinates copied", 1200);
}
} catch (_e) {
showHint("Copy failed", 1500);
}
});
}
return row;
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
if (!hfAprsPacketsEl) {
updateHfAprsSummary();
updateHfAprsChipState();
return;
}
const visible = hfAprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (let i = 0; i < visible.length; i++) {
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
}
hfAprsPacketsEl.replaceChildren(fragment);
updateHfAprsSummary();
updateHfAprsChipState();
}
window.resetHfAprsHistoryView = function() {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
};
window.pruneHfAprsHistoryView = function() {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
};
function addHfAprsPacket(pkt) {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt) {
return {
rig_id: pkt.rig_id || null,
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
srcCall: pkt.src_call,
destCall: pkt.dest_call,
path: pkt.path,
info: pkt.info,
info_bytes: pkt.info_bytes,
type: pkt.packet_type,
crcOk: pkt.crc_ok,
ts_ms: pkt.ts_ms,
lat: pkt.lat,
lon: pkt.lon,
symbolTable: pkt.symbol_table,
symbolCode: pkt.symbol_code,
};
}
window.onServerHfAprsBatch = function(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
normalized.push(next);
}
normalized.reverse();
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
};
window.restoreHfAprsHistory = function(packets) {
window.onServerHfAprsBatch(packets);
};
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
});
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
if (!confirm("Clear all HF APRS decode history? This cannot be undone.")) return;
try {
await postPath("/clear_hf_aprs_decode");
window.resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
}
});
if (hfAprsOnlyPosBtn) {
hfAprsOnlyPosBtn.addEventListener("click", () => {
hfAprsOnlyPos = !hfAprsOnlyPos;
renderHfAprsHistory();
});
}
if (hfAprsHideCrcBtn) {
hfAprsHideCrcBtn.addEventListener("click", () => {
hfAprsHideCrc = !hfAprsHideCrc;
renderHfAprsHistory();
});
}
if (hfAprsCollapseDupBtn) {
hfAprsCollapseDupBtn.addEventListener("click", () => {
hfAprsCollapseDup = !hfAprsCollapseDup;
renderHfAprsHistory();
});
}
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
const btn = document.getElementById(`hf-aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
hfAprsTypeFilter = type;
renderHfAprsHistory();
});
});
if (hfAprsFilterInput) {
hfAprsFilterInput.addEventListener("input", () => {
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
renderHfAprsHistory();
});
}
// --- Server-side HF APRS decode handler ---
window.onServerHfAprs = function(pkt) {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
};
renderHfAprsHistory();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import vm from "node:vm";
class ClassList {
constructor() { this.values = new Set(); }
add(...names) { names.forEach(name => this.values.add(name)); }
remove(...names) { names.forEach(name => this.values.delete(name)); }
contains(name) { return this.values.has(name); }
toggle(name, force) {
const enabled = force === undefined ? !this.contains(name) : force;
if (enabled) this.add(name); else this.remove(name);
return enabled;
}
}
class Element {
constructor(tagName, document) {
this.tagName = tagName.toUpperCase();
this.ownerDocument = document;
this.children = [];
this.dataset = {};
this.attributes = new Map();
this.classList = new ClassList();
this.listeners = new Map();
this.style = {};
this.options = [];
this.value = "";
this.textContent = "";
}
set id(value) { this._id = value; if (value) this.ownerDocument.elements.set(value, this); }
get id() { return this._id || ""; }
set className(value) { this.classList = new ClassList(); value.split(/\s+/).filter(Boolean).forEach(name => this.classList.add(name)); }
set innerHTML(value) {
this._innerHTML = value;
if (value.includes('value="confirm"')) {
const title = new Element("h2", this.ownerDocument); title.id = "ui-confirm-title";
const message = new Element("p", this.ownerDocument); message.id = "ui-confirm-message";
const confirm = new Element("button", this.ownerDocument); confirm.value = "confirm";
this.append(title, message, confirm);
this._confirmButton = confirm;
}
}
get innerHTML() { return this._innerHTML || ""; }
appendChild(child) { this.children.push(child); child.parentElement = this; return child; }
append(...children) { children.forEach(child => this.appendChild(child)); }
insertBefore(child) { return this.appendChild(child); }
remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter(child => child !== this); }
replaceChildren(...children) { this.children = []; this.options = []; this.append(...children); }
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name); }
addEventListener(type, listener) { this.listeners.set(type, listener); }
dispatch(type, event = {}) { this.listeners.get(type)?.({ target: this, preventDefault() {}, ...event }); }
querySelector(selector) {
if (selector === '[value="confirm"]') return this._confirmButton || null;
return null;
}
querySelectorAll(selector) {
if (selector === ".tab[data-tab]") return this.children.filter(child => child.dataset.tab);
if (selector === ".sub-tab[data-subtab]") return this.children.filter(child => child.dataset.subtab);
if (selector === '[role="tab"]') return this.children.filter(child => child.getAttribute("role") === "tab");
return [];
}
add(option) { this.options.push(option); if (!this.value) this.value = option.value; }
showModal() { this.open = true; }
close(value) { this.returnValue = value; this.open = false; this.dispatch("close"); }
focus() { this.focused = true; }
click() { this.clicked = true; }
}
class DocumentFixture {
constructor() {
this.readyState = "loading";
this.elements = new Map();
this.body = new Element("body", this);
}
createElement(tagName) { return new Element(tagName, this); }
getElementById(id) { return this.elements.get(id) || null; }
querySelector() { return null; }
querySelectorAll() { return []; }
addEventListener() {}
}
const document = new DocumentFixture();
const storage = new Map();
const localStorage = {
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
setItem(key, value) { storage.set(key, String(value)); },
};
const window = { document, localStorage, addEventListener() {} };
const context = vm.createContext({
window, document, localStorage,
Option: class Option { constructor(label, value) { this.label = label; this.value = value; } },
MutationObserver: class MutationObserver { observe() {} },
requestAnimationFrame(callback) { callback(); },
setTimeout() { return 1; },
clearTimeout() {},
console,
});
const source = await readFile(new URL("../ui-core.js", import.meta.url), "utf8");
new vm.Script(source, { filename: "ui-core.js" }).runInContext(context);
const ui = window.trxUi;
const toast = ui.notify("Saved", { kind: "success" });
assert.equal(toast.getAttribute("role"), "status");
assert.equal(toast.classList.contains("toast-success"), true);
assert.equal(document.getElementById("toast-region").children.length, 1);
const confirmation = ui.confirm({ title: "Delete?", message: "Permanent", confirmLabel: "Delete" });
const dialog = document.getElementById("ui-confirm-dialog");
assert.equal(dialog.open, true);
assert.equal(document.getElementById("ui-confirm-title").textContent, "Delete?");
dialog.close("confirm");
assert.equal(await confirmation, true);
const tabBar = new Element("div", document);
const firstTab = new Element("button", document); firstTab.dataset.tab = "main"; firstTab.classList.add("active");
const secondTab = new Element("button", document); secondTab.dataset.tab = "map";
tabBar.append(firstTab, secondTab);
const mainPanel = new Element("section", document); mainPanel.id = "tab-main";
const mapPanel = new Element("section", document); mapPanel.id = "tab-map";
ui.prepareTabList(tabBar, "primary");
assert.equal(firstTab.getAttribute("aria-selected"), "true");
tabBar.dispatch("keydown", { target: firstTab, key: "ArrowRight" });
assert.equal(secondTab.focused, true);
assert.equal(secondTab.clicked, true);
localStorage.setItem("trxOperatorLayout:rig-a", "broadcast");
ui.setActiveRig("rig-a");
assert.equal(document.body.dataset.operatorLayout, "compact");
ui.setLayoutCapabilities({ broadcast: true });
ui.setActiveRig("rig-a");
assert.equal(document.body.dataset.operatorLayout, "broadcast");
ui.setActiveRig("rig-b");
ui.applyLayout("digital");
assert.equal(document.body.dataset.operatorLayout, "compact");
assert.equal(localStorage.getItem("trxOperatorLayout:rig-b"), "compact");
console.log("ui-core component tests passed");
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

@@ -2,8 +2,53 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
use std::fmt::Write as _;
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
fn generate_asset_manifest() {
let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir"));
let generated_dir = manifest_dir.join("assets/web/generated");
println!("cargo:rerun-if-changed={}", generated_dir.display());
let mut filenames = fs::read_dir(&generated_dir)
.expect("read generated frontend assets")
.map(|entry| entry.expect("read generated asset entry"))
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
.map(|entry| {
entry
.file_name()
.into_string()
.expect("UTF-8 asset filename")
})
.collect::<Vec<_>>();
filenames.sort();
let mut output = String::from(
"// Generated by build.rs from committed frontend output.\n\
pub const GENERATED_ASSETS: &[(&str, &[u8])] = &[\n",
);
for filename in filenames {
assert!(
filename
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)),
"unsupported generated asset filename: {filename}"
);
writeln!(
output,
" ({filename:?}, include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/assets/web/generated/\", {filename:?}))),"
)
.expect("write asset manifest");
}
output.push_str("];\n");
let output_path =
PathBuf::from(std::env::var_os("OUT_DIR").expect("out dir")).join("generated_assets.rs");
fs::write(output_path, output).expect("write generated asset manifest");
}
fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) { fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
let days = secs.div_euclid(86_400); let days = secs.div_euclid(86_400);
let z = days + 719_468; let z = days + 719_468;
@@ -20,6 +65,7 @@ fn utc_ymd_from_unix_secs(secs: i64) -> (i32, u32, u32) {
} }
fn main() { fn main() {
generate_asset_manifest();
let secs = match SystemTime::now().duration_since(UNIX_EPOCH) { let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(d) => d.as_secs() as i64, Ok(d) => d.as_secs() as i64,
Err(_) => 0, Err(_) => 0,
@@ -0,0 +1,67 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
use std::fmt::Write as _;
use std::fs;
use std::path::PathBuf;
use trx_core::radio::freq::{Band, Freq};
use trx_core::rig::state::{SpectrumData, VchanRdsEntry};
use trx_core::rig::{
RigAccessMethod, RigCapabilities, RigInfo, RigRxStatus, RigStatus, RigTxStatus, RigVfo,
RigVfoEntry,
};
use trx_core::{DecoderConfig, RdsData, RigFilterState, RigMode, RigSnapshot, WfmDenoiseLevel};
use trx_frontend_http::server::api::rig::{RigListItem, RigListResponse};
use trx_frontend_http::server::api::FrontendMeta;
use trx_protocol::{DecoderActivation, DecoderDescriptor};
use ts_rs::{Config, TS};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut output = format!(
"// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>\n\
//\n\
// SPDX-License-{}: GPL-2.0-or-later\n\n\
// Generated by `cargo run -p trx-frontend-http --example generate_typescript`.\n\
// Do not edit manually.\n\n",
"Identifier",
);
let config = Config::default();
macro_rules! export {
($type:ty) => {
writeln!(output, "export {}\n", <$type as TS>::decl(&config))?;
};
}
export!(Band);
export!(Freq);
export!(RigMode);
export!(RigAccessMethod);
export!(RigCapabilities);
export!(RigInfo);
export!(RigVfoEntry);
export!(RigVfo);
export!(RigTxStatus);
export!(RigRxStatus);
export!(RigStatus);
export!(DecoderConfig);
export!(WfmDenoiseLevel);
export!(RigFilterState);
export!(RdsData);
export!(SpectrumData);
export!(VchanRdsEntry);
export!(RigSnapshot);
export!(RigListItem);
export!(RigListResponse);
export!(FrontendMeta);
export!(DecoderActivation);
export!(DecoderDescriptor);
let output_path =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("frontend/src/api/generated.ts");
fs::create_dir_all(output_path.parent().expect("generated file has a parent"))?;
fs::write(output_path, output)?;
Ok(())
}
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { build } from "esbuild";
import { rm } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import path from "node:path";
const frontendDir = path.dirname(fileURLToPath(import.meta.url));
const sourceDir = path.join(frontendDir, "src");
const outputDir = path.join(frontendDir, "..", "assets", "web", "generated");
await rm(outputDir, { recursive: true, force: true });
await build({
entryPoints: {
app: path.join(sourceDir, "bootstrap.ts"),
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
cw: path.join(sourceDir, "plugins", "cw.ts"),
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
ais: path.join(sourceDir, "plugins", "ais.ts"),
aprs: path.join(sourceDir, "plugins", "aprs.ts"),
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
sat: path.join(sourceDir, "plugins", "sat.ts"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
"map-core": path.join(sourceDir, "map-core.ts"),
screenshot: path.join(sourceDir, "screenshot.ts"),
},
outdir: outputDir,
bundle: true,
format: "esm",
splitting: true,
entryNames: "[name]",
chunkNames: "chunk-[hash]",
platform: "browser",
target: "es2022",
sourcemap: false,
legalComments: "inline",
charset: "utf8",
logLevel: "info",
});
await build({
entryPoints: [path.join(sourceDir, "decode-history-worker.ts")],
outfile: path.join(outputDir, "decode-history-worker.js"),
bundle: true,
format: "iife",
platform: "browser",
target: "es2022",
sourcemap: false,
legalComments: "inline",
charset: "utf8",
logLevel: "info",
});
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import eslint from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
"../assets/web/generated/**",
],
},
{
files: ["**/*.mjs"],
...eslint.configs.recommended,
languageOptions: {
globals: globals.node,
},
},
...tseslint.configs.recommendedTypeChecked.map((config) => ({
...config,
files: ["src/**/*.ts", "tests/**/*.ts"],
})),
{
files: ["src/**/*.ts", "tests/**/*.ts"],
ignores: ["src/decode-history-worker.ts"],
languageOptions: {
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
caughtErrors: "none",
varsIgnorePattern: "^_",
}],
// DOM event targets intentionally ignore listener return values. Async
// listeners handle their own failures; keep the rule for all other
// promise misuse sites.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
},
},
{
files: ["src/decode-history-worker.ts"],
languageOptions: {
globals: globals.worker,
parserOptions: {
project: "./tsconfig.worker.json",
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
},
},
);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
{
"name": "trx-frontend-http-web",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"build": "node build.mjs",
"generate-types": "cargo run -p trx-frontend-http --example generate_typescript",
"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 && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs",
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
},
"devDependencies": {
"@eslint/js": "9.39.2",
"@types/leaflet": "1.9.22",
"esbuild": "0.25.12",
"eslint": "9.39.2",
"globals": "16.5.0",
"playwright-core": "1.62.1",
"typescript": "5.9.3",
"typescript-eslint": "8.51.0"
}
}
@@ -0,0 +1,53 @@
<!--
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Frontend source
This directory contains the strict TypeScript source for the browser assets
embedded by `trx-frontend-http`. `bootstrap.ts` is the production entry point;
esbuild follows its imports and emits `/app.js`. Feature plugins are ESM entry
points loaded on demand by `plugin-loader.ts`, and the decode-history worker is
built with its own Web Worker TypeScript configuration.
Run all frontend commands from the parent `frontend` directory:
```sh
npm ci
npm run typecheck
npm run lint
npm test
npm run test:browser
npm run build
npm run verify-generated
```
`npm run verify-generated` regenerates Rust wire contracts and browser bundles,
then rejects drift from the committed files. The browser smoke test needs a
local Chromium-family executable; set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
when it is not installed at `/usr/bin/chromium`.
Cargo consumes committed output under `../assets/web/generated`. It never
invokes Node.js, installs packages, or accesses the network. After changing a
source file, commit the deterministic generated asset changes together with the
source change.
## Boundaries
- `api/generated.ts` is generated from Rust and must not be edited manually.
- `api/client.ts` owns runtime validation at version-sensitive HTTP and SSE
boundaries.
- `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.
First-party features communicate through imports, the typed plugin runtime, or
the documented `window.trx` host interface. New standalone `window` callbacks
are not permitted.
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AuthRole = "rx" | "control";
export interface AuthSession {
authenticated: boolean;
role?: AuthRole;
auth_disabled?: boolean;
}
function decodeAuthSession(value: unknown): AuthSession {
if (typeof value !== "object" || value === null) {
throw new TypeError("The authentication response is malformed");
}
const session = value as Record<string, unknown>;
if (typeof session.authenticated !== "boolean") {
throw new TypeError("The authentication response has no authenticated flag");
}
if (session.role !== undefined && session.role !== "rx" && session.role !== "control") {
throw new TypeError("The authentication response has an invalid role");
}
if (session.auth_disabled !== undefined && typeof session.auth_disabled !== "boolean") {
throw new TypeError("The authentication response has an invalid auth_disabled flag");
}
const decoded: AuthSession = { authenticated: session.authenticated };
if (session.role !== undefined) decoded.role = session.role;
if (session.auth_disabled !== undefined) decoded.auth_disabled = session.auth_disabled;
return decoded;
}
const authDisabledSession: AuthSession = {
authenticated: true,
role: "control",
auth_disabled: true,
};
export async function fetchAuthSession(): Promise<AuthSession> {
try {
const response = await fetch("/auth/session");
if (response.status === 404) return authDisabledSession;
if (!response.ok) return { authenticated: false };
return decodeAuthSession(await response.json());
} catch (error: unknown) {
console.error("Auth check failed:", error);
return { authenticated: false };
}
}
export async function login(passphrase: string): Promise<AuthSession> {
const response = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
});
if (response.status === 404) return authDisabledSession;
if (!response.ok) {
const message = await response.text();
throw new Error(message || "Login failed");
}
return decodeAuthSession(await response.json());
}
export async function logout(): Promise<void> {
const response = await fetch("/auth/logout", { method: "POST" });
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
}
@@ -0,0 +1,134 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import type {
DecoderDescriptor,
RigListResponse,
RigSnapshot,
} from "./generated";
export class ApiError extends Error {
public constructor(
public readonly status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
type Validator<T> = (value: unknown) => value is T;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isRigSnapshot(value: unknown): value is RigSnapshot {
if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) {
return false;
}
const { info, status } = value;
return (
typeof value.initialized === "boolean" &&
typeof info.manufacturer === "string" &&
typeof info.model === "string" &&
isRecord(status.freq) &&
typeof status.freq.hz === "number" &&
(typeof status.mode === "string" || isRecord(status.mode)) &&
typeof status.tx_en === "boolean"
);
}
export function isRigListResponse(value: unknown): value is RigListResponse {
return (
isRecord(value) &&
(value.active_remote === null || typeof value.active_remote === "string") &&
Array.isArray(value.rigs) &&
value.rigs.every(
(rig) =>
isRecord(rig) &&
typeof rig.remote === "string" &&
typeof rig.manufacturer === "string" &&
typeof rig.model === "string" &&
Array.isArray(rig.supported_modes) &&
typeof rig.tx === "boolean" &&
typeof rig.filter_controls === "boolean" &&
typeof rig.initialized === "boolean",
)
);
}
export function isDecoderRegistry(value: unknown): value is DecoderDescriptor[] {
return (
Array.isArray(value) &&
value.every(
(decoder) =>
isRecord(decoder) &&
typeof decoder.id === "string" &&
typeof decoder.label === "string" &&
(decoder.activation === "mode_bound" || decoder.activation === "toggle") &&
Array.isArray(decoder.active_modes) &&
decoder.active_modes.every((mode) => typeof mode === "string") &&
typeof decoder.background_decode === "boolean" &&
typeof decoder.bookmark_selectable === "boolean",
)
);
}
export class TrxApi {
public constructor(private readonly baseUrl = "") {}
public async get<T>(path: string, validate: Validator<T>): Promise<T> {
return this.request(path, { cache: "no-store" }, validate);
}
public async post<T>(
path: string,
body: unknown,
validate: Validator<T>,
): Promise<T> {
return this.request(
path,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
validate,
);
}
private async request<T>(
path: string,
init: RequestInit,
validate: Validator<T>,
): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, init);
if (!response.ok) {
const detail = await response.text();
throw new ApiError(response.status, detail || response.statusText);
}
const value: unknown = await response.json();
if (!validate(value)) {
throw new ApiError(response.status, `Malformed response from ${path}`);
}
return value;
}
}
export function decodeServerEvent<T>(
event: MessageEvent<string>,
validate: Validator<T>,
): T {
let value: unknown;
try {
value = JSON.parse(event.data) as unknown;
} catch (error: unknown) {
throw new TypeError("Server event is not valid JSON", { cause: error });
}
if (!validate(value)) {
throw new TypeError("Server event has an unexpected shape");
}
return value;
}
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
// Generated by `cargo run -p trx-frontend-http --example generate_typescript`.
// Do not edit manually.
export type Band = { low_hz: number, high_hz: number, tx_allowed: boolean, };
export type Freq = { hz: number, };
export type RigMode = "LSB" | "USB" | "CW" | "CWR" | "AM" | "SAM" | "WFM" | "FM" | "AIS" | "VDES" | "DIG" | "PKT" | { "Other": string };
export type RigAccessMethod = { "Serial": { path: string, baud: number, } } | { "Tcp": { addr: string, } };
export type RigCapabilities = { min_freq_step_hz: number, supported_bands: Array<Band>, supported_modes: Array<RigMode>, num_vfos: number, lock: boolean, lockable: boolean, attenuator: boolean, preamp: boolean, rit: boolean, rpt: boolean, split: boolean,
/**
* Backend supports transmit: PTT, power on/off, TX meters, TX audio.
*/
tx: boolean,
/**
* Backend supports get_tx_limit / set_tx_limit.
*/
tx_limit: boolean,
/**
* Backend supports toggle_vfo.
*/
vfo_switch: boolean,
/**
* Backend supports runtime filter adjustment (bandwidth).
*/
filter_controls: boolean,
/**
* Backend returns a meaningful RX signal strength value.
*/
signal_meter: boolean, };
export type RigInfo = { manufacturer: string, model: string, revision: string, capabilities: RigCapabilities, access: RigAccessMethod, };
export type RigVfoEntry = { name: string, freq: Freq, mode: RigMode | null, };
export type RigVfo = { entries: Array<RigVfoEntry>,
/**
* Index into `entries` for the active VFO, if known.
*/
active: number | null, };
export type RigTxStatus = { power: number | null, limit: number | null, swr: number | null, alc: number | null, };
export type RigRxStatus = { sig: number | null, };
export type RigStatus = { freq: Freq, mode: RigMode, tx_en: boolean, vfo: RigVfo | null, tx: RigTxStatus | null, rx: RigRxStatus | null, lock: boolean | null, };
export type DecoderConfig = { aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
export type WfmDenoiseLevel = "off" | "auto" | "low" | "medium" | "high";
export type RigFilterState = { bandwidth_hz: number, cw_center_hz: number, sdr_gain_db?: number | null, sdr_lna_gain_db?: number | null, sdr_agc_enabled?: boolean | null, sdr_squelch_enabled?: boolean | null, sdr_squelch_threshold_db?: number | null, sdr_nb_enabled?: boolean | null, sdr_nb_threshold?: number | null, wfm_deemphasis_us: number, wfm_stereo: boolean, wfm_stereo_detected: boolean, wfm_denoise: WfmDenoiseLevel,
/**
* Co-Channel Interference level (0100 scale).
*/
wfm_cci: number,
/**
* Adjacent Channel Interference level (0100 scale).
*/
wfm_aci: number,
/**
* SAM stereo width (0.0 = mono, 1.0 = full stereo).
*/
sam_stereo_width: number,
/**
* SAM carrier synchronization enabled.
*/
sam_carrier_sync: boolean, };
export type RdsData = { pi?: number | null, program_service?: string | null, radio_text?: string | null, program_type_name_long?: string | null, pty?: number | null, pty_name?: string | null, traffic_program?: boolean | null, traffic_announcement?: boolean | null, music?: boolean | null, stereo?: boolean | null, artificial_head?: boolean | null, compressed?: boolean | null, dynamic_pty?: boolean | null, alternative_frequencies_hz?: Array<number> | null, };
export type SpectrumData = {
/**
* FFT magnitude bins in dBFS, FFT-shifted so DC (centre frequency) is at index N/2.
*/
bins: Array<number>,
/**
* Centre frequency of the SDR capture in Hz.
*/
center_hz: number,
/**
* SDR capture sample rate in Hz; the displayed span is ±sample_rate/2.
*/
sample_rate: number,
/**
* Decoded Radio Data System state, when available for WFM.
*/
rds?: RdsData | null, };
export type VchanRdsEntry = {
/**
* Virtual channel UUID.
*/
id: string,
/**
* Latest RDS data, if decoded.
*/
rds?: RdsData | null,
/**
* Channel signal level in dBFS.
*/
signal_db?: number | null, };
export type RigSnapshot = { info: RigInfo, status: RigStatus, band: string | null, enabled: boolean | null, initialized: boolean, server_callsign?: string | null, server_version?: string | null, server_build_date?: string | null, server_latitude?: number | null, server_longitude?: number | null, pskreporter_status?: string | null, aprs_is_status?: string | null, cw_auto: boolean, cw_wpm: number, cw_tone_hz: number, filter?: RigFilterState | null, spectrum?: SpectrumData | null,
/**
* Per-virtual-channel RDS snapshots, when available.
*/
vchan_rds?: Array<VchanRdsEntry> | null, aprs_decode_enabled: boolean, hf_aprs_decode_enabled: boolean, cw_decode_enabled: boolean, ft8_decode_enabled: boolean, ft4_decode_enabled: boolean, ft2_decode_enabled: boolean, wspr_decode_enabled: boolean, lrpt_decode_enabled: boolean, wefax_decode_enabled: boolean, recorder_enabled: boolean, };
export type RigListItem = { remote: string, display_name: string | null, manufacturer: string, model: string, supported_modes: Array<RigMode>, tx: boolean, filter_controls: boolean, initialized: boolean, latitude: number | null, longitude: number | null, };
export type RigListResponse = { active_remote: string | null, rigs: Array<RigListItem>, };
export type FrontendMeta = { clients: number, rigctl_clients: number, audio_clients: number, rigctl_addr: string | null, active_remote: string | null, remotes: Array<string>, owner_callsign: string | null, owner_website_url: string | null, owner_website_name: string | null, ais_vessel_url_base: string | null, show_sdr_gain_control: boolean, initial_map_zoom: number, spectrum_coverage_margin_hz: number, spectrum_usable_span_ratio: number, bandplan_enabled: boolean, bandplan_region: string, decode_history_retention_min: bigint, server_connected: boolean, };
export type DecoderActivation = "mode_bound" | "toggle";
export type DecoderDescriptor = {
/**
* Machine identifier, e.g. `"ft8"`, `"aprs"`.
*/
id: string,
/**
* Human-readable label, e.g. `"FT8"`, `"APRS"`.
*/
label: string,
/**
* How the decoder is activated.
*/
activation: DecoderActivation,
/**
* Rig modes where this decoder operates (upper-case).
*/
active_modes: Array<string>,
/**
* Whether the decoder can run on SDR virtual channels
* (background-decode / scheduler).
*/
background_decode: boolean,
/**
* Whether this decoder should appear in bookmark forms.
*/
bookmark_selectable: boolean, };
@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import "./webgl-renderer.js";
import "./ui-core.js";
import "./plugin-runtime.js";
import "./leaflet-ais-tracksymbol.js";
import "./app.js";
@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type CborValue = number | string | boolean | null | undefined
| CborValue[] | { [key: string]: CborValue };
interface DecodeState { offset: number }
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
function decodeUint(
view: DataView,
bytes: Uint8Array,
state: DecodeState,
additional: number,
): number {
const offset = state.offset;
if (additional < 24) return additional;
const widths: Partial<Record<number, number>> = { 24: 1, 25: 2, 26: 4, 27: 8 };
const width = widths[additional];
if (width === undefined) throw new Error("Unsupported CBOR additional info");
if (offset + width > bytes.length) throw new Error("CBOR payload truncated");
state.offset += width;
if (additional === 24) return bytes[offset] ?? 0;
if (additional === 25) return view.getUint16(offset);
if (additional === 26) return view.getUint32(offset);
const numeric = Number(view.getBigUint64(offset));
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
return numeric;
}
function decodeFloat16(bits: number): number {
const sign = (bits & 0x8000) ? -1 : 1;
const exponent = (bits >> 10) & 0x1f;
const fraction = bits & 0x03ff;
if (exponent === 0) return fraction === 0 ? sign * 0 : sign * 2 ** -14 * (fraction / 1024);
if (exponent === 0x1f) return fraction === 0 ? sign * Infinity : Number.NaN;
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
}
function decodeItem(view: DataView, bytes: Uint8Array, state: DecodeState): CborValue {
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
const initial = bytes[state.offset++];
if (initial === undefined) throw new Error("CBOR payload truncated");
const major = initial >> 5;
const additional = initial & 0x1f;
if (major === 0) return decodeUint(view, bytes, state, additional);
if (major === 1) return -1 - decodeUint(view, bytes, state, additional);
if (major === 2 || major === 3) {
const length = decodeUint(view, bytes, state, additional);
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
const chunk = bytes.subarray(state.offset, state.offset + length);
state.offset += length;
if (major === 2) return Array.from(chunk);
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
}
if (major === 4) {
const length = decodeUint(view, bytes, state, additional);
return Array.from({ length }, () => decodeItem(view, bytes, state));
}
if (major === 5) {
const length = decodeUint(view, bytes, state, additional);
const value: Record<string, CborValue> = {};
for (let index = 0; index < length; index += 1) {
const key = decodeItem(view, bytes, state);
if (typeof key !== "string" && typeof key !== "number" && typeof key !== "boolean"
&& key !== null) {
throw new Error("Unsupported composite CBOR map key");
}
value[String(key)] = decodeItem(view, bytes, state);
}
return value;
}
if (major === 6) {
decodeUint(view, bytes, state, additional);
return decodeItem(view, bytes, state);
}
if (major === 7) {
if (additional === 20) return false;
if (additional === 21) return true;
if (additional === 22) return null;
if (additional === 23) return undefined;
const widths: Partial<Record<number, number>> = { 25: 2, 26: 4, 27: 8 };
const width = widths[additional];
if (width === undefined) throw new Error("Unsupported CBOR major type");
if (state.offset + width > bytes.length) throw new Error("CBOR payload truncated");
const offset = state.offset;
state.offset += width;
if (additional === 25) return decodeFloat16(view.getUint16(offset));
if (additional === 26) return view.getFloat32(offset);
return view.getFloat64(offset);
}
throw new Error("Unsupported CBOR major type");
}
export function decodeCbor(buffer: ArrayBuffer | Uint8Array): CborValue {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const state = { offset: 0 };
const value = decodeItem(view, bytes, state);
if (state.offset !== bytes.length) throw new Error("Unexpected trailing bytes in CBOR payload");
return value;
}
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export interface DecoderDescriptor {
id: string;
label: string;
activation: string;
active_modes: string[];
background_decode: boolean;
bookmark_selectable: boolean;
}
interface DecoderRegistryBridge {
decoderRegistry?: DecoderDescriptor[];
onDecoderRegistryReady?: (callback: () => void) => void;
}
const bridge = window as DecoderRegistryBridge;
const readyCallbacks: Array<() => void> = [];
export let decoderRegistry: DecoderDescriptor[] = [];
export function onDecoderRegistryReady(callback: () => void): void {
if (decoderRegistry.length > 0) callback();
else readyCallbacks.push(callback);
}
export function applyDecoderRegistryVisibility(): void {
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
const alwaysShow = new Set(["overview", "rds", "sat"]);
document.querySelectorAll<HTMLElement>(
"#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]",
).forEach((button) => {
const id = button.dataset.subtab;
if (!id || alwaysShow.has(id) || knownIds.has(id)) return;
button.style.display = "none";
const panel = document.getElementById(`subtab-${id}`);
if (panel) panel.style.display = "none";
});
document.querySelectorAll<HTMLElement>('[id^="about-dec-"]').forEach((element) => {
const id = element.id.replace("about-dec-", "");
if (alwaysShow.has(id) || knownIds.has(id)) return;
const row = element.closest<HTMLElement>("tr");
if (row) row.style.display = "none";
});
document.querySelectorAll<HTMLElement>('[id^="settings-clear-"][id$="-history"]')
.forEach((element) => {
const match = /^settings-clear-(.+)-history$/.exec(element.id);
const id = match?.[1];
if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none";
});
document.querySelectorAll<HTMLElement>("#subtab-overview .plugin-item[data-decoder]")
.forEach((element) => {
const id = element.dataset.decoder;
if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none";
});
}
function isDecoderDescriptor(value: unknown): value is DecoderDescriptor {
if (typeof value !== "object" || value === null) return false;
const item = value as Partial<DecoderDescriptor>;
return typeof item.id === "string"
&& typeof item.label === "string"
&& typeof item.activation === "string"
&& Array.isArray(item.active_modes)
&& item.active_modes.every((mode) => typeof mode === "string")
&& typeof item.background_decode === "boolean"
&& typeof item.bookmark_selectable === "boolean";
}
function decodeRegistry(value: unknown): DecoderDescriptor[] {
if (!Array.isArray(value) || !value.every(isDecoderDescriptor)) {
throw new TypeError("The decoder registry response is malformed");
}
return value;
}
export async function loadDecoderRegistry(onLoaded: () => void): Promise<void> {
try {
const response = await fetch("/decoders");
if (!response.ok) return;
decoderRegistry = decodeRegistry(await response.json());
bridge.decoderRegistry = decoderRegistry;
readyCallbacks.splice(0).forEach((callback) => { callback(); });
applyDecoderRegistryVisibility();
onLoaded();
} catch (error: unknown) {
console.error("Failed to fetch decoder registry:", error);
}
}
// Temporary compatibility boundary for lazy modules that have not yet been
// changed to receive the registry service through their plugin context.
bridge.decoderRegistry = decoderRegistry;
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export function escapeHtml(input: unknown): string {
return String(input)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export function formatDuration(milliseconds: number): string {
const seconds = Math.floor(milliseconds / 1000);
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainder = seconds % 60;
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0 || days > 0) parts.push(`${hours}h`);
parts.push(`${minutes}m`, `${remainder}s`);
return parts.join(" ");
}
export function formatFrequency(frequencyHz: number): string {
if (!Number.isFinite(frequencyHz)) return "--";
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
if (frequencyHz >= 10_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
return `${(frequencyHz / 1_000).toFixed(1)} kHz`;
}
export function formatFrequencyForStep(frequencyHz: number, stepHz: number): string {
if (!Number.isFinite(frequencyHz)) return "--";
if (stepHz >= 1_000_000) return (frequencyHz / 1_000_000).toFixed(6);
if (stepHz >= 1_000) return (frequencyHz / 1_000).toFixed(3);
if (stepHz >= 1) return String(Math.round(frequencyHz));
return formatFrequency(frequencyHz);
}
export function formatFrequencyForHumans(frequencyHz: number): string {
if (!Number.isFinite(frequencyHz)) return "--";
if (frequencyHz >= 1_000_000_000) return `${(frequencyHz / 1_000_000_000).toFixed(3)} GHz`;
if (frequencyHz >= 1_000_000) return `${(frequencyHz / 1_000_000).toFixed(3)} MHz`;
if (frequencyHz >= 1_000) return `${(frequencyHz / 1_000).toFixed(3)} kHz`;
return `${Math.round(frequencyHz)} Hz`;
}
export function formatWavelength(frequencyHz: number): string {
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--";
const meters = 299_792_458 / frequencyHz;
return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`;
}
export function parseFrequencyInput(
value: string,
defaultStepHz: number,
mode: string,
): number | null {
if (!value) return null;
const match = /^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/
.exec(value.trim().toLowerCase());
if (!match?.[1]) return null;
const rawNumber = match[1];
let frequency = Number.parseFloat(rawNumber.replace(",", "."));
const unit = match[2] ?? "";
if (Number.isNaN(frequency)) return null;
if (unit.startsWith("gh") || unit === "g") frequency *= 1_000_000_000;
else if (unit.startsWith("mh") || unit === "m") frequency *= 1_000_000;
else if (unit.startsWith("kh") || unit === "k") frequency *= 1_000;
else if (!unit) {
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
if (mode.toUpperCase() === "WFM") {
if (hasDecimalSeparator && frequency >= 50 && frequency < 200) {
return Math.round(frequency * 1_000_000);
}
if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) {
return Math.round((frequency / 10) * 1_000_000);
}
}
if (defaultStepHz >= 1_000_000) frequency *= 1_000_000;
else if (defaultStepHz >= 1_000) frequency *= 1_000;
else if (defaultStepHz < 1) {
if (frequency < 1_000) frequency *= 1_000_000;
else if (frequency < 1_000_000) frequency *= 1_000;
}
}
return Math.round(frequency);
}
export function formatByteSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1_048_576).toFixed(1)} MB`;
}
@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export interface LatLon {
readonly lat: number;
readonly lon: number;
}
export function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
const radiusKm = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
return radiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function locatorToLatLon(locator: unknown): LatLon | null {
const raw = typeof locator === "string" ? locator.trim().toUpperCase() : "";
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(raw)) return null;
let lon = -180 + (raw.charCodeAt(0) - 65) * 20 + Number(raw.slice(2, 3)) * 2;
let lat = -90 + (raw.charCodeAt(1) - 65) * 10 + Number(raw.slice(3, 4));
if (raw.length >= 6) {
lon += (raw.charCodeAt(4) - 65) * (5 / 60) + 2.5 / 60;
lat += (raw.charCodeAt(5) - 65) * (2.5 / 60) + 1.25 / 60;
} else {
lon += 1;
lat += 0.5;
}
return { lat, lon };
}
export function formatDistanceKm(distanceKm: number): string | null {
if (!Number.isFinite(distanceKm)) return null;
return distanceKm < 1 ? `${Math.round(distanceKm * 1000)} m` : `${distanceKm.toFixed(1)} km`;
}
export function formatTimeAgo(timestampMs: number): string | null {
if (!timestampMs) return null;
const seconds = Math.round((Date.now() - timestampMs) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} min ago`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}min ago` : `${hours}h ago`;
}
export function latLonToMaidenhead(lat: number, lon: number): string {
const adjustedLon = lon + 180;
const adjustedLat = lat + 90;
const upperA = "A".charCodeAt(0);
const lowerA = "a".charCodeAt(0);
const field1 = String.fromCharCode(upperA + Math.floor(adjustedLon / 20));
const field2 = String.fromCharCode(upperA + Math.floor(adjustedLat / 10));
const square1 = Math.floor((adjustedLon % 20) / 2);
const square2 = Math.floor(adjustedLat % 10);
const sub1 = String.fromCharCode(lowerA + Math.floor((adjustedLon % 2) * 12));
const sub2 = String.fromCharCode(lowerA + Math.floor((adjustedLat % 1) * 24));
return `${field1}${field2}${square1}${square2}${sub1}${sub2}`;
}
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
const STORAGE_PREFIX = "trx_";
export function saveSetting(key: string, value: unknown): void {
try {
localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value));
} catch {
// Storage can be unavailable in private browsing or under a strict policy.
}
}
export function loadSetting<T>(key: string, fallback: T): T {
try {
const value = localStorage.getItem(`${STORAGE_PREFIX}${key}`);
return value === null ? fallback : JSON.parse(value) as T;
} catch {
return fallback;
}
}
@@ -2,16 +2,27 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; export {};
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
function decodeCborUint(view, bytes, state, additional) { const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"] as const;
type HistoryGroup = (typeof HISTORY_GROUP_KEYS)[number];
type CborValue = number | string | boolean | null | undefined | CborValue[] | { [key: string]: CborValue };
interface DecodeState { offset: number }
interface FetchHistoryRequest { type: "fetch-history"; url?: string; batchLimit?: number }
interface WorkerScope {
postMessage(message: unknown): void;
onmessage: ((event: MessageEvent<unknown>) => void) | null;
}
const workerScope = self as unknown as WorkerScope;
function decodeCborUint(view: DataView, bytes: Uint8Array, state: DecodeState, additional: number): number {
const offset = state.offset; const offset = state.offset;
if (additional < 24) return additional; if (additional < 24) return additional;
if (additional === 24) { if (additional === 24) {
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
state.offset += 1; state.offset += 1;
return bytes[offset]; return bytes[offset] ?? 0;
} }
if (additional === 25) { if (additional === 25) {
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
@@ -34,7 +45,7 @@ function decodeCborUint(view, bytes, state, additional) {
throw new Error("Unsupported CBOR additional info"); throw new Error("Unsupported CBOR additional info");
} }
function decodeCborFloat16(bits) { function decodeCborFloat16(bits: number): number {
const sign = (bits & 0x8000) ? -1 : 1; const sign = (bits & 0x8000) ? -1 : 1;
const exponent = (bits >> 10) & 0x1f; const exponent = (bits >> 10) & 0x1f;
const fraction = bits & 0x03ff; const fraction = bits & 0x03ff;
@@ -47,9 +58,10 @@ function decodeCborFloat16(bits) {
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024)); return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
} }
function decodeCborItem(view, bytes, state) { function decodeCborItem(view: DataView, bytes: Uint8Array, state: DecodeState): CborValue {
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
const initial = bytes[state.offset++]; const initial = bytes[state.offset++];
if (initial === undefined) throw new Error("CBOR payload truncated");
const major = initial >> 5; const major = initial >> 5;
const additional = initial & 0x1f; const additional = initial & 0x1f;
if (major === 0) return decodeCborUint(view, bytes, state, additional); if (major === 0) return decodeCborUint(view, bytes, state, additional);
@@ -70,7 +82,7 @@ function decodeCborItem(view, bytes, state) {
} }
if (major === 4) { if (major === 4) {
const length = decodeCborUint(view, bytes, state, additional); const length = decodeCborUint(view, bytes, state, additional);
const items = new Array(length); const items: CborValue[] = new Array<CborValue>(length);
for (let i = 0; i < length; i += 1) { for (let i = 0; i < length; i += 1) {
items[i] = decodeCborItem(view, bytes, state); items[i] = decodeCborItem(view, bytes, state);
} }
@@ -78,10 +90,13 @@ function decodeCborItem(view, bytes, state) {
} }
if (major === 5) { if (major === 5) {
const length = decodeCborUint(view, bytes, state, additional); const length = decodeCborUint(view, bytes, state, additional);
const value = {}; const value: Record<string, CborValue> = {};
for (let i = 0; i < length; i += 1) { for (let i = 0; i < length; i += 1) {
const key = decodeCborItem(view, bytes, state); const key = decodeCborItem(view, bytes, state);
value[String(key)] = decodeCborItem(view, bytes, state); const property = typeof key === "string" || typeof key === "number" || typeof key === "boolean"
? String(key)
: JSON.stringify(key);
value[property] = decodeCborItem(view, bytes, state);
} }
return value; return value;
} }
@@ -116,7 +131,7 @@ function decodeCborItem(view, bytes, state) {
throw new Error("Unsupported CBOR major type"); throw new Error("Unsupported CBOR major type");
} }
function decodeCborPayload(buffer) { function decodeCborPayload(buffer: ArrayBuffer | Uint8Array): CborValue {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const state = { offset: 0 }; const state = { offset: 0 };
@@ -127,35 +142,39 @@ function decodeCborPayload(buffer) {
return value; return value;
} }
async function fetchAndDecodeHistory(url, batchLimit) { function isHistory(value: CborValue): value is Partial<Record<HistoryGroup, CborValue[]>> {
self.postMessage({ type: "status", phase: "fetching" }); return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function fetchAndDecodeHistory(url: string, batchLimit?: number) {
workerScope.postMessage({ type: "status", phase: "fetching" });
const resp = await fetch(url, { credentials: "same-origin" }); const resp = await fetch(url, { credentials: "same-origin" });
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`); if (!resp.ok) throw new Error(`History fetch failed: ${String(resp.status)}`);
const payload = await resp.arrayBuffer(); const payload = await resp.arrayBuffer();
if (!payload || payload.byteLength === 0) { if (payload.byteLength === 0) {
self.postMessage({ type: "start", total: 0 }); workerScope.postMessage({ type: "start", total: 0 });
self.postMessage({ type: "done", total: 0 }); workerScope.postMessage({ type: "done", total: 0 });
return; return;
} }
self.postMessage({ type: "status", phase: "decoding" }); workerScope.postMessage({ type: "status", phase: "decoding" });
const history = decodeCborPayload(payload); const history = decodeCborPayload(payload);
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => { const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
const items = history && Array.isArray(history[key]) ? history[key] : []; const items = isHistory(history) && Array.isArray(history[key]) ? history[key] : [];
return sum + items.length; return sum + items.length;
}, 0); }, 0);
self.postMessage({ type: "start", total }); workerScope.postMessage({ type: "start", total });
let processed = 0; let processed = 0;
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512)); const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
for (const kind of HISTORY_GROUP_KEYS) { for (const kind of HISTORY_GROUP_KEYS) {
const items = history && Array.isArray(history[kind]) ? history[kind] : []; const items = isHistory(history) && Array.isArray(history[kind]) ? history[kind] : [];
if (items.length === 0) continue; if (items.length === 0) continue;
for (let index = 0; index < items.length; index += safeLimit) { for (let index = 0; index < items.length; index += safeLimit) {
const messages = items.slice(index, index + safeLimit); const messages = items.slice(index, index + safeLimit);
processed += messages.length; processed += messages.length;
self.postMessage({ workerScope.postMessage({
type: "group", type: "group",
kind, kind,
messages, messages,
@@ -164,17 +183,22 @@ async function fetchAndDecodeHistory(url, batchLimit) {
}); });
} }
} }
self.postMessage({ type: "done", total }); workerScope.postMessage({ type: "done", total });
} }
self.onmessage = (event) => { function isFetchHistoryRequest(value: unknown): value is FetchHistoryRequest {
const data = event?.data || {}; return typeof value === "object" && value !== null && "type" in value && value.type === "fetch-history";
if (data?.type !== "fetch-history") return; }
workerScope.onmessage = (event) => {
const data = event.data;
if (!isFetchHistoryRequest(data)) return;
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit) fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
.catch((err) => { .catch((error: unknown) => {
self.postMessage({ workerScope.postMessage({
type: "error", type: "error",
message: err && err.message ? err.message : String(err || "unknown worker failure"), message: error instanceof Error ? error.message
: typeof error === "string" ? error : "unknown worker failure",
}); });
}); });
}; };
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export const TAB_ORDER = [
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
] as const;
export type TabName = typeof TAB_ORDER[number];
export const TAB_PATHS: Readonly<Record<TabName, string>> = {
main: "/",
bookmarks: "/bookmarks",
"digital-modes": "/digital-modes",
map: "/map",
statistics: "/statistics",
recorder: "/recorder",
settings: "/settings",
about: "/about",
};
export function normalizeTabPath(pathname: string): string {
const raw = pathname.length > 0 ? pathname : "/";
return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/";
}
export function tabFromPath(pathname: string): TabName {
const normalized = normalizeTabPath(pathname);
const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized);
return match ? match[0] as TabName : "main";
}
export function updateTabHistory(name: TabName, replace = false): void {
const targetPath = TAB_PATHS[name];
if (normalizeTabPath(window.location.pathname) === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
if (replace) window.history.replaceState({}, "", nextUrl);
else window.history.pushState({}, "", nextUrl);
}
@@ -0,0 +1,97 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export interface SpectrumFrame {
bins: readonly number[] | ArrayBufferView;
center_hz: number;
sample_rate: number;
}
export interface InterferenceLevels { aci?: number; cci?: number }
export type BandwidthLimits = readonly [defaultHz: number, minHz: number, maxHz: number, stepHz: number];
function clampPercent(value: unknown): number {
const numeric = Number(value) || 0;
return Math.max(0, Math.min(100, numeric)) / 100;
}
export function estimateOccupiedBandwidth(
data: SpectrumFrame | null,
centerHz: number,
mode: string,
limits: BandwidthLimits,
interference: InterferenceLevels = {},
): number | null {
if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins)
|| !Number.isFinite(centerHz)) return null;
const bins = Array.from(data.bins as ArrayLike<number>);
if (bins.length < 3) return null;
const maxIdx = bins.length - 1;
const hzPerBin = data.sample_rate / maxIdx;
const fullLoHz = data.center_hz - data.sample_rate / 2;
const centerIdx = Math.max(
1,
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
);
const normalizedMode = mode.toUpperCase();
const [defaultBw, minBw, maxBw, stepBw] = limits;
const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode)
? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0;
const isWfm = normalizedMode === "WFM";
const smoothRadius = isWfm ? 3 : 1;
const smoothed = bins.map((_, index) => {
let sum = 0;
let count = 0;
for (let adjacent = Math.max(0, index - smoothRadius);
adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) {
sum += bins[adjacent] ?? 0;
count += 1;
}
return sum / count;
});
const sorted = [...bins].sort((left, right) => left - right);
const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity;
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
let peak = -Infinity;
for (let index = searchLo; index <= searchHi; index += 1) {
peak = Math.max(peak, smoothed[index] ?? -Infinity);
}
const snr = peak - noise;
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin));
const occupiedExtent = (direction: -1 | 1, limitBins: number): number => {
let lastOccupied = centerIdx;
let gap = 0;
for (let offset = 0; offset <= limitBins; offset += 1) {
const index = centerIdx + direction * offset;
if (index <= 0 || index >= maxIdx) break;
if ((smoothed[index] ?? -Infinity) >= threshold) {
lastOccupied = index;
gap = 0;
} else if (++gap > allowedGap) break;
}
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
};
let rawBw = oneSided !== 0
? occupiedExtent(oneSided, maxSpanBins)
: 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins));
rawBw *= isWfm ? 1.08 : 1.12;
if (isWfm) {
const aci = clampPercent(interference.aci);
const cci = clampPercent(interference.cci);
const aciCap = maxBw - (maxBw - minBw) * aci;
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
const cciCap = maxBw - (maxBw - cciFloor) * cci;
rawBw = Math.min(rawBw, aciCap, cciCap);
}
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
}
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type NumericBins = number[] | Int8Array | Uint8Array | Float32Array | Float64Array;
export function isNumericBins(value: unknown): value is NumericBins {
return Array.isArray(value)
? value.every((item) => typeof item === "number")
: ArrayBuffer.isView(value) && !(value instanceof DataView);
}
const base64Lookup = new Uint8Array(128).fill(255);
const base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
for (let index = 0; index < base64Alphabet.length; index += 1) {
base64Lookup[base64Alphabet.charCodeAt(index)] = index;
}
let spectrumBinBuffer = new Int8Array(0);
export function decodeBase64Int8(value: string): Int8Array {
let end = value.length;
while (end > 0 && value.charCodeAt(end - 1) === 61) end -= 1;
const outputLength = end * 3 >>> 2;
if (spectrumBinBuffer.length !== outputLength) spectrumBinBuffer = new Int8Array(outputLength);
let outputIndex = 0;
for (let index = 0; index < end;) {
const sextets = [0, 0, 0, 0];
for (let offset = 0; offset < 4 && index < end; offset += 1, index += 1) {
const code = value.charCodeAt(index);
const decoded = code < base64Lookup.length ? base64Lookup[code] : undefined;
if (decoded === undefined || decoded === 255) throw new TypeError("Invalid base64 spectrum frame");
sextets[offset] = decoded;
}
const packed = ((sextets[0] ?? 0) << 18) | ((sextets[1] ?? 0) << 12)
| ((sextets[2] ?? 0) << 6) | (sextets[3] ?? 0);
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 16 & 0xff;
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 0xff;
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 0xff;
}
return spectrumBinBuffer;
}
let nthScratch = new Float64Array(0);
export function nthElement(values: NumericBins, target: number): number | null {
if (values.length === 0 || target < 0 || target >= values.length) return null;
if (nthScratch.length < values.length) nthScratch = new Float64Array(values.length);
for (let index = 0; index < values.length; index += 1) nthScratch[index] = values[index] ?? 0;
let low = 0;
let high = values.length - 1;
while (low < high) {
const pivot = nthScratch[low + ((high - low) >> 1)] ?? 0;
let left = low;
let right = high;
while (left <= right) {
while ((nthScratch[left] ?? Infinity) < pivot) left += 1;
while ((nthScratch[right] ?? -Infinity) > pivot) right -= 1;
if (left <= right) {
const temporary = nthScratch[left] ?? 0;
nthScratch[left] = nthScratch[right] ?? 0;
nthScratch[right] = temporary;
left += 1;
right -= 1;
}
}
if (right < target) low = left;
if (target < left) high = right;
}
return nthScratch[target] ?? null;
}
export function estimateNoiseFloorDb(bins: unknown): number | null {
if (!isNumericBins(bins) || bins.length === 0) return null;
return nthElement(bins, Math.floor(bins.length * 0.15));
}
@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export {};
interface AisSymbolOptions {
heading: number | null;
course: number | null;
speed: number | null;
color: string;
outline: string;
size: number;
interactive: boolean;
keyboard: boolean;
riseOnHover: boolean;
icon?: unknown;
}
interface LeafletMapAdapter { on(event: string, listener: () => void): void; off(event: string, listener: () => void): void; getZoom(): number }
interface TrackSymbolInstance {
options: AisSymbolOptions;
_icon?: HTMLElement;
_map?: LeafletMapAdapter;
_boundZoomRefresh?: (() => void) | null;
_refreshIcon(): void;
}
interface TrackSymbolConstructor { new(latlng: unknown, options?: Partial<AisSymbolOptions>): TrackSymbolInstance }
interface LeafletAdapter {
Marker: {
extend(definition: object & ThisType<TrackSymbolInstance>): TrackSymbolConstructor;
prototype: {
initialize(this: TrackSymbolInstance, latlng: unknown, options: AisSymbolOptions): void;
onAdd(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
onRemove(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
};
};
Util: { extend<T extends object>(target: T, ...sources: object[]): T };
divIcon(options: object): unknown;
TrxAisTrackSymbol?: TrackSymbolConstructor;
trxAisTrackSymbol?: (latlng: unknown, options?: Partial<AisSymbolOptions>) => TrackSymbolInstance;
}
(function() {
const leaflet = (globalThis as unknown as { L?: LeafletAdapter }).L;
if (!leaflet) return;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function finiteAngle(value: number | null): number | null {
if (value === null || !Number.isFinite(value)) return null;
const normalized = ((value % 360) + 360) % 360;
return normalized;
}
function svgColor(value: string, fallback: string): string {
const text = value || fallback || "";
return text.replace(/"/g, "&quot;");
}
function buildSymbolHtml(options: AisSymbolOptions, zoom: number): string {
const heading = finiteAngle(options.heading);
const course = finiteAngle(options.course);
const angle = heading != null ? heading : course;
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
const sizeBase = Number.isFinite(options.size) ? options.size : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
const color = svgColor(options.color, "#ff7559");
const outline = svgColor(options.outline, "#6b2118");
const body = angle != null
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
`</g>`
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
const courseLine = course != null
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
`</g>`
: "";
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
courseLine +
body +
`</svg>`
);
}
leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
options: {
heading: null,
course: null,
speed: null,
color: "#ff7559",
outline: "#6b2118",
size: 22,
interactive: true,
keyboard: true,
riseOnHover: true,
},
initialize: function(latlng: unknown, options?: Partial<AisSymbolOptions>) {
const merged = leaflet.Util.extend({} as AisSymbolOptions, this.options, options || {});
merged.icon = leaflet.divIcon({
className: "trx-ais-track-symbol-icon",
html: "",
iconSize: [merged.size, merged.size],
iconAnchor: [merged.size / 2, merged.size / 2],
});
leaflet.Marker.prototype.initialize.call(this, latlng, merged);
},
onAdd: function(map: LeafletMapAdapter) {
leaflet.Marker.prototype.onAdd.call(this, map);
this._refreshIcon();
this._boundZoomRefresh = this._refreshIcon.bind(this);
map.on("zoomend", this._boundZoomRefresh);
},
onRemove: function(map: LeafletMapAdapter) {
if (this._boundZoomRefresh) {
map.off("zoomend", this._boundZoomRefresh);
this._boundZoomRefresh = null;
}
leaflet.Marker.prototype.onRemove.call(this, map);
},
setAisState: function(next: Partial<AisSymbolOptions>) {
if ("heading" in next) this.options.heading = next.heading;
if ("course" in next) this.options.course = next.course;
if ("speed" in next) this.options.speed = next.speed;
if ("color" in next) this.options.color = next.color;
if ("outline" in next) this.options.outline = next.outline;
this._refreshIcon();
return this;
},
_refreshIcon: function() {
if (!this._icon) return;
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
const html = buildSymbolHtml(this.options, zoom);
this._icon.innerHTML = html;
const sizeBase = Number.isFinite(this.options.size) ? this.options.size : 22;
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
const size = clamp(sizeBase + zoomBoost, 16, 32);
this._icon.style.width = `${size}px`;
this._icon.style.height = `${size}px`;
this._icon.style.marginLeft = `${-size / 2}px`;
this._icon.style.marginTop = `${-size / 2}px`;
},
});
leaflet.trxAisTrackSymbol = function(latlng: unknown, options?: Partial<AisSymbolOptions>) {
const Constructor = leaflet.TrxAisTrackSymbol;
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
return new Constructor(latlng, options);
};
})();
@@ -0,0 +1,52 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
// with it. They used to come only with the map group, which left their
// sub-tabs empty — decodes queueing in the runtime — until something opened
// the Map tab. Their map calls are optional, so map-core stays lazy.
"digital-modes": [
"/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js",
"/sat.js", "/wefax.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js",
],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"],
};
const loaded = new Set<string>();
const loading = new Map<string, Promise<void>>();
async function loadPlugin(path: string): Promise<void> {
if (loaded.has(path)) return;
const pending = loading.get(path);
if (pending) return pending;
const request = import(path).then(() => {
loaded.add(path);
loading.delete(path);
}).catch((error: unknown) => {
loading.delete(path);
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
});
loading.set(path, request);
return request;
}
async function loadPlugins(group: string): Promise<void> {
if (!(group in pluginGroups)) return;
for (const path of pluginGroups[group as PluginGroup]) await loadPlugin(path);
}
export async function loadEagerPlugins(): Promise<void> {
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
}
export async function loadPluginsForTab(tab: string): Promise<void> {
await loadPlugins(tab);
}
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import type { DecoderPlugin, PluginRuntimeWindow, TrxPluginRuntime } from "./plugins/runtime-contract";
type QueuedAction =
| { kind: "message"; payload: unknown }
| { kind: "batch" | "restore"; payload: unknown[] };
const decoders = new Map<string, DecoderPlugin>();
const queued = new Map<string, QueuedAction[]>();
const MAX_QUEUED_ACTIONS_PER_DECODER = 512;
function enqueue(id: string, action: QueuedAction): void {
const actions = queued.get(id) ?? [];
actions.push(action);
if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER);
queued.set(id, actions);
}
function deliver(plugin: DecoderPlugin, action: QueuedAction): boolean {
if (action.kind === "message" && plugin.onMessage) {
plugin.onMessage(action.payload);
return true;
}
if (action.kind === "batch" && plugin.onBatch) {
plugin.onBatch(action.payload);
return true;
}
if (action.kind === "restore" && plugin.restore) {
plugin.restore(action.payload);
return true;
}
return false;
}
function dispatchOrQueue(id: string, action: QueuedAction): boolean {
const plugin = decoders.get(id);
if (!plugin) {
enqueue(id, action);
return false;
}
if (!deliver(plugin, action)) {
if (action.kind === "batch" && plugin.onMessage) {
for (const message of action.payload) plugin.onMessage(message);
return true;
}
if (action.kind === "restore" && plugin.onBatch) {
plugin.onBatch(action.payload);
return true;
}
return false;
}
return true;
}
const runtime: TrxPluginRuntime = {
registerDecoder<TMessage>(plugin: DecoderPlugin<TMessage>): () => void {
if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`);
const erased = plugin as DecoderPlugin;
decoders.set(plugin.id, erased);
const pending = queued.get(plugin.id) ?? [];
queued.delete(plugin.id);
for (const action of pending) deliver(erased, action);
return () => { if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id); };
},
dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }),
dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }),
restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }),
reset(id) {
const plugin = decoders.get(id);
if (!plugin?.reset) return false;
plugin.reset();
return true;
},
resetAll() { for (const plugin of decoders.values()) plugin.reset?.(); },
prune(id) {
const plugin = decoders.get(id);
if (!plugin?.prune) return false;
plugin.prune();
return true;
},
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
clearQueued() { queued.clear(); },
hasDecoder: (id) => decoders.has(id),
};
(window as unknown as PluginRuntimeWindow).trxPluginRuntime = runtime;
@@ -2,10 +2,48 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
export {};
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AisMessage {
rig_id?: string | null;
channel?: string | null;
message_type?: number | null;
mmsi?: number | null;
lat?: number | null;
lon?: number | null;
sog_knots?: number | null;
cog_deg?: number | null;
heading_deg?: number | null;
vessel_name?: string | null;
callsign?: string | null;
destination?: string | null;
ts_ms?: number | null;
_tsMs?: number;
_ts?: string;
}
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
interface AisBridge {
navigateToAprsMap?: (lat: number, lon: number) => void;
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
aisMapAddVessel?: (message: AisMessage) => void;
clearMapMarkersByType?: (type: string) => void;
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 => hostCore.escapeMapHtml(input);
// --- AIS Decoder Plugin (server-side decode) --- // --- AIS Decoder Plugin (server-side decode) ---
const aisStatus = document.getElementById("ais-status"); const aisStatus = document.getElementById("ais-status");
const aisMessagesEl = document.getElementById("ais-messages"); const aisMessagesEl = document.getElementById("ais-messages");
const aisFilterInput = document.getElementById("ais-filter"); const aisFilterInput = document.getElementById("ais-filter") as HTMLInputElement | null;
const aisBarOverlay = document.getElementById("ais-bar-overlay"); const aisBarOverlay = document.getElementById("ais-bar-overlay");
const aisChannelSummaryEl = document.getElementById("ais-channel-summary"); const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
const aisVesselCountEl = document.getElementById("ais-vessel-count"); const aisVesselCountEl = document.getElementById("ais-vessel-count");
@@ -14,41 +52,41 @@ const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
const AIS_DEFAULT_A_HZ = 161_975_000; const AIS_DEFAULT_A_HZ = 161_975_000;
const AIS_CHANNEL_SPACING_HZ = 50_000; const AIS_CHANNEL_SPACING_HZ = 50_000;
let aisFilterText = ""; let aisFilterText = "";
let aisMessageHistory = []; let aisMessageHistory: AisMessage[] = [];
function currentAisHistoryRetentionMs() { function currentAisHistoryRetentionMs(): number {
return typeof window.getDecodeHistoryRetentionMs === "function" return typeof aisWindow.getDecodeHistoryRetentionMs === "function"
? window.getDecodeHistoryRetentionMs() ? aisWindow.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000; : 24 * 60 * 60 * 1000;
} }
function pruneAisMessageHistory() { function pruneAisMessageHistory() {
const cutoffMs = Date.now() - currentAisHistoryRetentionMs(); const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
} }
function scheduleAisUi(key, job) { function scheduleAisUi(key: string, job: () => void): void {
if (typeof window.trxScheduleUiFrameJob === "function") { if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job); aisWindow.trxScheduleUiFrameJob(key, job);
return; return;
} }
job(); job();
} }
function scheduleAisHistoryRender() { function scheduleAisHistoryRender() {
scheduleAisUi("ais-history", () => renderAisHistory()); scheduleAisUi("ais-history", () => { renderAisHistory(); });
} }
function scheduleAisBarUpdate() { function scheduleAisBarUpdate() {
scheduleAisUi("ais-bar", () => updateAisBar()); scheduleAisUi("ais-bar", () => { updateAisBar(); });
} }
function formatAisMhz(freqHz) { function formatAisMhz(freqHz: number): string {
return `${(freqHz / 1_000_000).toFixed(3)} MHz`; return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
} }
function currentAisChannelPlan() { function currentAisChannelPlan(): { aHz: number; bHz: number } {
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); const raw = ((document.getElementById("freq") as HTMLInputElement | null)?.value || "").replace(/[^\d]/g, "");
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ; const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ; const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
return { return {
@@ -57,9 +95,9 @@ function currentAisChannelPlan() {
}; };
} }
function aisChannelInfo(channel) { function aisChannelInfo(channel: string | null | undefined): AisChannelInfo {
const plan = currentAisChannelPlan(); const plan = currentAisChannelPlan();
const ch = String(channel || "").trim().toUpperCase(); const ch = (channel ?? "").trim().toUpperCase();
if (ch === "B") { if (ch === "B") {
return { return {
label: "AIS-B", label: "AIS-B",
@@ -74,18 +112,18 @@ function aisChannelInfo(channel) {
}; };
} }
function aisDisplayName(msg) { function aisDisplayName(msg: AisMessage): string {
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`; return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
} }
function aisDisplayNameHtml(msg) { function aisDisplayNameHtml(msg: AisMessage): string {
const label = escapeMapHtml(aisDisplayName(msg)); const label = escapeAisHtml(aisDisplayName(msg));
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
if (!url) return label; if (!url) return label;
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`; return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
} }
function aisTypeLabel(type) { function aisTypeLabel(type: number | null | undefined): string {
switch (Number(type)) { switch (Number(type)) {
case 1: case 1:
case 2: case 2:
@@ -108,8 +146,8 @@ function aisTypeLabel(type) {
} }
} }
function aisAgeText(tsMs) { function aisAgeText(tsMs: number | undefined): string {
if (!Number.isFinite(tsMs)) return "just now"; if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
const deltaMs = Math.max(0, Date.now() - tsMs); const deltaMs = Math.max(0, Date.now() - tsMs);
const seconds = Math.round(deltaMs / 1000); const seconds = Math.round(deltaMs / 1000);
if (seconds < 5) return "just now"; if (seconds < 5) return "just now";
@@ -120,31 +158,31 @@ function aisAgeText(tsMs) {
return `${hours}h ago`; return `${hours}h ago`;
} }
function aisMotionText(msg) { function aisMotionText(msg: AisMessage): string {
const parts = [ const parts = [
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null, msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null, msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null,
].filter(Boolean); ].filter(Boolean);
return parts.join(" · "); return parts.join(" · ");
} }
function aisRouteText(msg) { function aisRouteText(msg: AisMessage): string {
return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
} }
function aisDistanceText(msg) { function aisDistanceText(msg: AisMessage): string {
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) { if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
return ""; return "";
} }
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon); const distKm = hostCore.haversineKm(hostState.serverLat, hostState.serverLon, msg.lat, msg.lon);
if (!Number.isFinite(distKm)) return ""; if (!Number.isFinite(distKm)) return "";
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
return `${distKm.toFixed(1)} km from TRX`; return `${distKm.toFixed(1)} km from TRX`;
} }
function aisLatestByVessel(messages) { function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
const byMmsi = new Map(); const byMmsi = new Map<string, AisMessage>();
for (const msg of messages) { for (const msg of messages) {
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`; const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
if (!byMmsi.has(key)) byMmsi.set(key, msg); if (!byMmsi.has(key)) byMmsi.set(key, msg);
@@ -175,8 +213,21 @@ function updateAisSummary() {
} }
} }
function renderAisRow(msg) { /** What the message says, in one line: where the vessel is and what it is
const row = document.createElement("div"); * doing, or for the static reports that carry no fix where it is going. */
function aisSummaryText(msg: AisMessage): string {
const parts: string[] = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg: AisMessage): HTMLElement {
const row = document.createElement("details");
row.className = "ais-message"; row.className = "ais-message";
const ts = msg._ts || new Date().toLocaleTimeString([], { const ts = msg._ts || new Date().toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@@ -190,8 +241,9 @@ function renderAisRow(msg) {
const route = aisRouteText(msg); const route = aisRouteText(msg);
const distance = aisDistanceText(msg); const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null const pos = msg.lat != null && msg.lon != null
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>`
: ""; : "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [ row.dataset.filterText = [
name, name,
msg.mmsi, msg.mmsi,
@@ -206,28 +258,48 @@ function renderAisRow(msg) {
.join(" ") .join(" ")
.toUpperCase(); .toUpperCase();
row.innerHTML = row.innerHTML =
`<div class="ais-row-head">` + `<summary class="decode-line">` +
`<span class="ais-time">${ts}</span>` + `<span class="ais-time">${escapeAisHtml(ts)}</span>` +
`<span class="ais-call">${nameHtml}</span>` + `<span class="ais-call">${nameHtml}</span>` +
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` + `<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` + `<span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span>` +
`</div>` + `<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
`<div class="ais-row-meta">` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") +
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` + `</summary>` +
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") + `<div class="decode-expanded">` +
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` + `<div class="decode-expanded-meta">` +
`</div>` + `<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
`<div class="ais-row-detail">` + `<span>${escapeAisHtml(channel.freqText)}</span>` +
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) + (route ? `<span>${escapeAisHtml(route)}</span>` : "") +
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") +
(pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(msg.lat != null && msg.lon != null
? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>`
: "") +
(vesselUrl
? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>`
: "") +
`</div>` +
`</div>`; `</div>`;
row.querySelectorAll<HTMLElement>("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row); applyAisFilterToRow(row);
return row; return row;
} }
function applyAisFilterToRow(row) { function applyAisFilterToRow(row: HTMLElement): void {
if (!aisFilterText) { if (!aisFilterText) {
row.style.display = ""; row.style.display = "";
return; return;
@@ -236,19 +308,13 @@ function applyAisFilterToRow(row) {
row.style.display = message.includes(aisFilterText) ? "" : "none"; row.style.display = message.includes(aisFilterText) ? "" : "none";
} }
function applyAisFilterToAll() {
if (!aisMessagesEl) return;
const rows = aisMessagesEl.querySelectorAll(".ais-message");
rows.forEach((row) => applyAisFilterToRow(row));
}
function updateAisBar() { function updateAisBar() {
if (!aisBarOverlay) return; if (!aisBarOverlay) return;
updateAisSummary(); updateAisSummary();
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS"; const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs); const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
const messages = aisLatestByVessel(recent).slice(0, 8); const messages = aisLatestByVessel(recent).slice(0, 8);
if (!isAis || messages.length === 0) { if (!isAis || messages.length === 0) {
aisBarOverlay.style.display = "none"; aisBarOverlay.style.display = "none";
@@ -266,12 +332,12 @@ function updateAisBar() {
const channel = aisChannelInfo(msg.channel); const channel = aisChannelInfo(msg.channel);
const distance = aisDistanceText(msg); const distance = aisDistanceText(msg);
const details = [ const details = [
`MMSI ${escapeMapHtml(String(msg.mmsi))}`, `MMSI ${escapeAisHtml(String(msg.mmsi))}`,
escapeMapHtml(channel.label), escapeAisHtml(channel.label),
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null, msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
distance ? escapeMapHtml(distance) : null, distance ? escapeAisHtml(distance) : null,
escapeMapHtml(aisAgeText(msg._tsMs)), escapeAisHtml(aisAgeText(msg._tsMs)),
] ]
.filter(Boolean) .filter(Boolean)
.join(" · "); .join(" · ");
@@ -282,18 +348,18 @@ function updateAisBar() {
aisBarOverlay.innerHTML = html; aisBarOverlay.innerHTML = html;
aisBarOverlay.style.display = "flex"; aisBarOverlay.style.display = "flex";
} }
window.updateAisBar = updateAisBar; aisWindow.updateAisBar = updateAisBar;
window.clearAisBar = function() { aisWindow.clearAisBar = function() {
window.resetAisHistoryView(); resetAisHistoryView();
}; };
window.resetAisHistoryView = function() { function resetAisHistoryView(): void {
if (aisMessagesEl) aisMessagesEl.innerHTML = ""; if (aisMessagesEl) aisMessagesEl.innerHTML = "";
aisMessageHistory = []; aisMessageHistory = [];
updateAisBar(); updateAisBar();
renderAisHistory(); renderAisHistory();
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais"); aisWindow.clearMapMarkersByType?.("ais");
}; }
function renderAisHistory() { function renderAisHistory() {
pruneAisMessageHistory(); pruneAisMessageHistory();
@@ -302,14 +368,14 @@ function renderAisHistory() {
return; return;
} }
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < aisMessageHistory.length; i += 1) { for (const message of aisMessageHistory) {
fragment.appendChild(renderAisRow(aisMessageHistory[i])); fragment.appendChild(renderAisRow(message));
} }
aisMessagesEl.replaceChildren(fragment); aisMessagesEl.replaceChildren(fragment);
updateAisSummary(); updateAisSummary();
} }
function addAisMessage(msg) { function addAisMessage(msg: AisMessage): void {
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
msg._tsMs = tsMs; msg._tsMs = tsMs;
msg._ts = new Date(tsMs).toLocaleTimeString([], { msg._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -323,33 +389,26 @@ function addAisMessage(msg) {
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) { plotAisMessage(msg);
window.aisMapAddVessel(msg);
}
} }
function normalizeServerAisMessage(msg) { /** Hands a positioned message to the map, if the map module is loaded yet. */
function plotAisMessage(msg: AisMessage): void {
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
aisWindow.aisMapAddVessel(msg);
}
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
return { return {
...msg,
rig_id: msg.rig_id || null, rig_id: msg.rig_id || null,
channel: msg.channel,
message_type: msg.message_type,
mmsi: msg.mmsi,
lat: msg.lat,
lon: msg.lon,
sog_knots: msg.sog_knots,
cog_deg: msg.cog_deg,
heading_deg: msg.heading_deg,
vessel_name: msg.vessel_name,
callsign: msg.callsign,
destination: msg.destination,
ts_ms: msg.ts_ms,
}; };
} }
window.onServerAisBatch = function(messages) { function onServerAisBatch(messages: AisMessage[]): void {
if (!Array.isArray(messages) || messages.length === 0) return; if (!Array.isArray(messages) || messages.length === 0) return;
if (aisStatus) aisStatus.textContent = "Receiving"; if (aisStatus) aisStatus.textContent = "Receiving";
const normalized = []; const normalized: AisMessage[] = [];
for (const msg of messages) { for (const msg of messages) {
const next = normalizeServerAisMessage(msg); const next = normalizeServerAisMessage(msg);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
@@ -359,9 +418,7 @@ window.onServerAisBatch = function(messages) {
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
}); });
if (next.lat != null && next.lon != null && window.aisMapAddVessel) { plotAisMessage(next);
window.aisMapAddVessel(next);
}
normalized.push(next); normalized.push(next);
} }
normalized.reverse(); normalized.reverse();
@@ -369,27 +426,23 @@ window.onServerAisBatch = function(messages) {
pruneAisMessageHistory(); pruneAisMessageHistory();
scheduleAisBarUpdate(); scheduleAisBarUpdate();
scheduleAisHistoryRender(); scheduleAisHistoryRender();
}; }
window.restoreAisHistory = function(messages) { function pruneAisHistoryView(): void {
window.onServerAisBatch(messages);
};
window.pruneAisHistoryView = function() {
pruneAisMessageHistory(); pruneAisMessageHistory();
updateAisBar(); updateAisBar();
renderAisHistory(); renderAisHistory();
}; }
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => {
if (!confirm("Clear all AIS decode history? This cannot be undone.")) return; if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await postPath("/clear_ais_decode"); await hostCore.postPath("/clear_ais_decode");
window.resetAisHistoryView(); resetAisHistoryView();
} catch (e) { } catch (e) {
console.error("AIS history clear failed", e); console.error("AIS history clear failed", e);
} }
}); })(); });
if (aisFilterInput) { if (aisFilterInput) {
aisFilterInput.addEventListener("input", () => { aisFilterInput.addEventListener("input", () => {
@@ -398,10 +451,19 @@ if (aisFilterInput) {
}); });
} }
window.onServerAis = function(msg) { function onServerAis(msg: AisMessage): void {
if (aisStatus) aisStatus.textContent = "Receiving"; if (aisStatus) aisStatus.textContent = "Receiving";
addAisMessage(normalizeServerAisMessage(msg)); addAisMessage(normalizeServerAisMessage(msg));
}; }
updateAisSummary(); updateAisSummary();
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais"); (window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AisMessage>({
id: "ais",
onMessage: onServerAis,
onBatch: onServerAisBatch,
restore: onServerAisBatch,
reset: resetAisHistoryView,
prune: pruneAisHistoryView,
// Oldest first, so vessel tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
});
@@ -0,0 +1,393 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export type AprsCategory = "position" | "message" | "weather" | "telemetry" | "other";
export type AprsTypeFilter = "all" | AprsCategory;
export interface AprsPacket {
rig_id?: string | null;
receiver?: unknown;
srcCall?: string;
destCall?: string;
path?: string;
info?: string;
info_bytes?: number[];
type?: string;
crcOk?: boolean;
ts_ms?: number | null;
lat?: number | null;
lon?: number | null;
symbolTable?: string | null;
symbolCode?: string | null;
_tsMs?: number;
_ts?: string;
src_call?: string;
dest_call?: string;
packet_type?: string;
crc_ok?: boolean;
symbol_table?: string | null;
symbol_code?: string | null;
}
function escapeAprsHtml(value: string): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function aprsPacketCategory(packet: AprsPacket): AprsCategory {
const type = (packet.type ?? "").toLowerCase();
const info = (packet.info ?? "").toLowerCase();
if ((packet.lat != null && packet.lon != null) || type.includes("position")) return "position";
if (type.includes("message") || info.startsWith(":")) return "message";
if (type.includes("weather") || info.startsWith("_")) return "weather";
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
return "other";
}
export function aprsCategoryLabel(category: AprsCategory): string {
switch (category) {
case "position": return "Position";
case "message": return "Message";
case "weather": return "Weather";
case "telemetry": return "Telemetry";
default: return "Other";
}
}
export function aprsAgeText(timestampMs: number | undefined): string {
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1000);
if (seconds < 5) return "just now";
if (seconds < 60) return `${String(seconds)}s ago`;
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${String(minutes)}m ago`;
return `${String(Math.round(minutes / 60))}h ago`;
}
export function aprsPacketSignature(packet: AprsPacket): string {
return [packet.srcCall ?? "", packet.destCall ?? "", packet.path ?? "", packet.info ?? "", packet.type ?? "",
packet.lat?.toFixed(4) ?? "", packet.lon?.toFixed(4) ?? ""].join("|");
}
export function collapseAprsDuplicates(packets: AprsPacket[]): AprsPacket[] {
const seen = new Set<string>();
return packets.filter((packet) => {
const signature = aprsPacketSignature(packet);
if (seen.has(signature)) return false;
seen.add(signature);
return true;
});
}
export function aprsHexBytes(bytes: number[] | undefined): string {
if (!bytes?.length) return "--";
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
}
export function renderAprsInfo(packet: AprsPacket): string {
if (packet.info_bytes?.length) {
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
}
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
}
function renderAprsByte(byte: number): string {
return byte >= 0x20 && byte <= 0x7e
? escapeAprsCharacter(String.fromCharCode(byte))
: `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function renderAprsCharacter(character: string): string {
const code = character.charCodeAt(0);
return code >= 0x20 && code <= 0x7e
? escapeAprsCharacter(character)
: `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
}
function escapeAprsCharacter(character: string): string {
if (character === "<") return "&lt;";
if (character === ">") return "&gt;";
if (character === "&") return "&amp;";
if (character === '"') return "&quot;";
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}`,
};
}
/** An empty slot of the symbol's size, so a frame without one still lines up
* with the frames around it in the list. */
export function renderAprsSymbolSlot(packet: AprsPacket, escapeHtml: (value: string) => string): string {
return renderLocalAprsSymbol(packet, escapeHtml)
|| '<span class="aprs-symbol aprs-symbol-empty" aria-hidden="true"></span>';
}
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>`;
}
export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): AprsPacket {
return {
rig_id: packet.rig_id || null,
receiver,
srcCall: packet.src_call ?? "",
destCall: packet.dest_call ?? "",
path: packet.path ?? "",
info: packet.info ?? "",
info_bytes: packet.info_bytes ?? [],
type: packet.packet_type ?? "",
crcOk: packet.crc_ok ?? false,
ts_ms: packet.ts_ms ?? null,
lat: packet.lat ?? null,
lon: packet.lon ?? null,
symbolTable: packet.symbol_table ?? null,
symbolCode: packet.symbol_code ?? null,
};
}
// ── Payload summaries ──────────────────────────────────────────────────────
// APRS packs its meaning into the information field with a set of one-character
// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The
// list showed that field as it arrives on the air, so reading a weather report
// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line
// of plain text for the common types and leave the raw field to the expanded
// view, which is still the authority when a summary cannot be made.
/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */
function fahrenheitToCelsius(fahrenheit: number): number {
return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10;
}
/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */
function summarizeAprsWeather(info: string): string | null {
const parts: string[] = [];
const temperature = /t(-?\d{2,3})/.exec(info);
if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`);
const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info);
if (wind) {
const gust = /g(\d{3})/.exec(info);
const knots = Number(wind[2]);
parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`);
}
const humidity = /h(\d{2})/.exec(info);
if (humidity) {
const value = Number(humidity[1]);
parts.push(`${value === 0 ? 100 : value}% RH`);
}
const pressure = /b(\d{5})/.exec(info);
if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`);
const rain = /r(\d{3})/.exec(info);
if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`);
return parts.length ? parts.join(" · ") : null;
}
/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */
function summarizeAprsTelemetry(info: string): string | null {
const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim());
if (!match?.[2]) return null;
const channels = match[2].split(",").filter((value) => value.length > 0);
const bits = match[3] ? ` · bits ${match[3]}` : "";
return `#${match[1]} · ${channels.join(" ")}${bits}`;
}
/** `:DEST :text{01` → addressed message text. */
function summarizeAprsMessage(info: string): string | null {
const match = /^:([^:]{9}):(.*)$/.exec(info);
if (!match?.[1] || match[2] == null) return null;
const addressee = match[1].trim();
const text = match[2].replace(/\{\d+\s*$/, "").trim();
return `${addressee}: ${text}`;
}
/** Course/speed appended to a position, as `088/036`. */
function summarizeAprsCourseSpeed(info: string): string | null {
const match = /(\d{3})\/(\d{3})/.exec(info);
if (!match) return null;
const knots = Number(match[2]);
if (knots === 0) return null;
return `${Number(match[1])}° ${knots} kt`;
}
/** Whatever a frame is worth saying in one line, or null to fall back to raw. */
export function summarizeAprsPayload(packet: AprsPacket): string | null {
const info = packet.info ?? "";
if (!info) return null;
const category = aprsPacketCategory(packet);
if (category === "message") return summarizeAprsMessage(info);
if (category === "weather") return summarizeAprsWeather(info);
if (category === "telemetry") return summarizeAprsTelemetry(info);
if (category === "position") {
const parts: string[] = [];
if (packet.lat != null && packet.lon != null) {
parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`);
}
const courseSpeed = summarizeAprsCourseSpeed(info);
if (courseSpeed) parts.push(courseSpeed);
// Whatever the station wrote after the position report.
const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim();
if (comment && comment.length <= 60) parts.push(comment);
return parts.length ? parts.join(" · ") : null;
}
// Status and anything else: the text it carries, minus its type character.
const text = info.replace(/^[>;<?]/, "").trim();
return text.length ? text : null;
}
// ── Frame row ──────────────────────────────────────────────────────────────
// Shared by the APRS and HF APRS lists, which had a copy each of the same
// forty lines of markup and drifted only by one badge.
export interface AprsRowOptions {
/** Marks the newest frame so it can flash on arrival. */
fresh?: boolean;
/** Leading badge, e.g. the band a copy of this list is dedicated to. */
badge?: string;
/** Distance from the receiver, already formatted, or "" to leave it out. */
distance?: string;
/** Opens the map on a frame's position. */
onMap?: (lat: number, lon: number) => void;
/** Puts a frame's coordinates on the clipboard. */
onCopy?: (text: string, button: HTMLElement) => void;
}
export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement {
const row = document.createElement("details");
row.className = "aprs-packet";
if (!packet.crcOk) row.classList.add("aprs-packet-crc");
if (options.fresh) row.classList.add("aprs-packet-new");
const time = packet._ts
|| new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
const category = aprsPacketCategory(packet);
const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML =
`<summary class="decode-line">` +
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
renderAprsSymbolSlot(packet, escapeAprsHtml) +
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
`<span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` +
(packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') +
(options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") +
`</summary>` +
`<div class="decode-expanded">` +
`<div class="decode-expanded-meta">` +
`<span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span>` +
`<span>${escapeAprsHtml(packet.path || "no path")}</span>` +
`<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` +
`<span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` +
(hasPosition
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">`
+ `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>`
: "") +
`</div>` +
`<div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` +
(packet.info_bytes?.length
? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>`
: "") +
`<div class="aprs-row-actions">` +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") +
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
`</div>` +
`</div>`;
row.querySelectorAll<HTMLElement>("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number);
if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number);
});
});
const copyButton = row.querySelector<HTMLElement>("[data-aprs-copy]");
if (copyButton) {
copyButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton);
});
}
return row;
}
@@ -0,0 +1,341 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface AprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: 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;
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 => 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");
const aprsPacketsEl = document.getElementById("aprs-packets");
const aprsFilterInput = document.getElementById("aprs-filter") as HTMLInputElement | null;
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
const aprsTotalCountEl = document.getElementById("aprs-total-count");
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
let aprsFilterText = "";
let aprsPacketHistory: AprsPacket[] = [];
let aprsBarDismissedAtMs = 0;
let aprsOnlyPos = false;
let aprsHideCrc = false;
let aprsCollapseDup = false;
let aprsTypeFilter: AprsTypeFilter = "all";
function currentAprsHistoryRetentionMs(): number {
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function"
? aprsWindow.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneAprsPacketHistory() {
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
}
function scheduleAprsUi(key: string, job: () => void): void {
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
aprsWindow.trxScheduleUiFrameJob(key, job);
return;
}
job();
}
function scheduleAprsHistoryRender() {
scheduleAprsUi("aprs-history", () => { renderAprsHistory(); });
}
function scheduleAprsBarUpdate() {
scheduleAprsUi("aprs-bar", () => { updateAprsBar(); });
}
function aprsDistanceText(pkt: AprsPacket): string {
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`;
}
function aprsFilterMatch(pkt: AprsPacket): boolean {
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (aprsHideCrc && !pkt.crcOk) return false;
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
if (!aprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
aprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(aprsFilterText);
}
function aprsVisiblePackets(): AprsPacket[] {
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
return packets.filter(aprsFilterMatch);
}
function updateAprsSummary() {
const visible = aprsVisiblePackets();
if (aprsTotalCountEl) {
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
}
if (aprsVisibleCountEl) {
aprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (aprsLatestSeenEl) {
const latest = aprsPacketHistory[0];
if (!latest) {
aprsLatestSeenEl.textContent = "No packets yet";
} else {
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
}
}
}
function updateAprsChipState() {
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
});
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
}
async function copyAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (!clipboard) return;
await clipboard.writeText(text);
showAprsHint("Coordinates copied", 1200);
} catch {
showAprsHint("Copy failed", 1500);
}
}
function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
distance: aprsDistanceText(pkt),
onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text) => { void copyAprsCoords(text); },
});
}
function renderAprsHistory() {
pruneAprsPacketHistory();
if (!aprsPacketsEl) {
updateAprsSummary();
updateAprsChipState();
return;
}
const visible = aprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (const [index, packet] of visible.entries()) {
fragment.appendChild(renderAprsRow(packet, index === 0));
}
aprsPacketsEl.replaceChildren(fragment);
updateAprsSummary();
updateAprsChipState();
}
function updateAprsBar() {
if (!aprsBarOverlay) return;
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
return;
}
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">&times;</button></span></div>';
for (const pkt of frames) {
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
const dest = escapeAprsHtml(pkt.destCall || "");
const info = escapeAprsHtml(pkt.info || "");
const pin = pkt.lat != null && pkt.lon != null
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
: "";
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div>` +
`</div>`;
}
aprsBarOverlay.innerHTML = html;
aprsBarOverlay.style.display = "flex";
}
aprsWindow.updateAprsBar = updateAprsBar;
aprsWindow.clearAprsBar = function() {
resetAprsHistoryView();
};
aprsWindow.closeAprsBar = function() {
aprsBarDismissedAtMs = Date.now();
if (aprsBarOverlay) {
aprsBarOverlay.style.display = "none";
aprsBarOverlay.innerHTML = "";
}
};
function resetAprsHistoryView(): void {
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
aprsPacketHistory = [];
updateAprsBar();
renderAprsHistory();
aprsWindow.clearMapMarkersByType?.("aprs");
}
function pruneAprsHistoryView(): void {
pruneAprsPacketHistory();
updateAprsBar();
renderAprsHistory();
}
/** Hands a positioned packet to the map, if the map module is loaded yet. */
function plotAprsPacket(pkt: AprsPacket): void {
if (pkt.lat == null || pkt.lon == null || !aprsWindow.aprsMapAddStation) return;
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
}
function addAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
aprsPacketHistory.unshift(pkt);
pruneAprsPacketHistory();
plotAprsPacket(pkt);
if (pkt.crcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
}
function onServerAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (aprsStatus) aprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
let hasCrcOk = false;
for (const pkt of packets) {
const next = normalizeServerAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
plotAprsPacket(next);
if (next.crcOk) hasCrcOk = true;
normalized.push(next);
}
normalized.reverse();
aprsPacketHistory = normalized.concat(aprsPacketHistory);
pruneAprsPacketHistory();
if (hasCrcOk) scheduleAprsBarUpdate();
scheduleAprsHistoryRender();
}
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 hostCore.postPath("/clear_aprs_decode");
resetAprsHistoryView();
} catch (e) {
console.error("APRS history clear failed", e);
}
})(); });
if (aprsOnlyPosBtn) {
aprsOnlyPosBtn.addEventListener("click", () => {
aprsOnlyPos = !aprsOnlyPos;
renderAprsHistory();
});
}
if (aprsHideCrcBtn) {
aprsHideCrcBtn.addEventListener("click", () => {
aprsHideCrc = !aprsHideCrc;
renderAprsHistory();
});
}
if (aprsCollapseDupBtn) {
aprsCollapseDupBtn.addEventListener("click", () => {
aprsCollapseDup = !aprsCollapseDup;
renderAprsHistory();
});
}
(["all", "position", "message", "weather", "telemetry", "other"] as const).forEach((type) => {
const btn = document.getElementById(`aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
aprsTypeFilter = type;
renderAprsHistory();
});
});
if (aprsFilterInput) {
aprsFilterInput.addEventListener("input", () => {
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
renderAprsHistory();
});
}
// --- Server-side APRS decode handler ---
function onServerAprs(pkt: AprsPacket): void {
if (aprsStatus) aprsStatus.textContent = "Receiving";
addAprsPacket(normalizeServerAprsPacket(pkt));
}
renderAprsHistory();
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AprsPacket>({
id: "aprs",
onMessage: onServerAprs,
onBatch: onServerAprsBatch,
restore: onServerAprsBatch,
reset: resetAprsHistoryView,
prune: pruneAprsHistoryView,
// Oldest first, so station tracks are rebuilt in the order they happened.
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
});
@@ -2,30 +2,79 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostState } from "./host.js";
export {};
interface DecoderDescriptor {
id: string;
background_decode?: boolean;
activation?: string;
active_modes: string[];
}
interface Bookmark {
id: string;
name: string;
freq_hz: number;
mode: string;
decoders?: string[];
}
interface BackgroundDecodeConfig {
remote: string | null;
enabled: boolean;
bookmark_ids: string[];
}
interface BackgroundStatusEntry {
bookmark_name?: string;
bookmark_id?: string;
freq_hz?: number;
mode?: string;
decoder_kinds?: string[];
state?: string;
}
interface BackgroundDecodeStatus {
entries?: BackgroundStatusEntry[];
active_rig?: boolean;
center_hz?: number;
sample_rate?: number;
}
interface BackgroundBridge {
decoderRegistry?: DecoderDescriptor[];
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
trx?: { modules?: { backgroundDecode?: BackgroundDecodeService } };
}
interface BackgroundDecodeService {
initialize(rigId: string | null, role: string | null): void;
wireEvents(): void;
setRig(rigId: string | null): void;
}
interface WiredElement extends HTMLElement { _wired?: boolean }
const bgdWindow = window as unknown as BackgroundBridge;
(function () { (function () {
"use strict"; "use strict";
function bgdSupportedIds() { function bgdSupportedIds(): string[] {
return (window.decoderRegistry || []) return (bgdWindow.decoderRegistry || [])
.filter(function (d) { return d.background_decode; }) .filter(function (d) { return d.background_decode; })
.map(function (d) { return d.id; }); .map(function (d) { return d.id; });
} }
let backgroundDecodeRole = null; let backgroundDecodeRole: string | null = null;
let currentRigId = null; let currentRigId: string | null = null;
let currentConfig = null; let currentConfig: BackgroundDecodeConfig | null = null;
let bookmarkList = []; let bookmarkList: Bookmark[] = [];
let statusInterval = null; let statusInterval: ReturnType<typeof setInterval> | null = null;
let bgdDirty = false; let bgdDirty = false;
function initBackgroundDecode(rigId, role) { function initBackgroundDecode(rigId: string | null, role: string | null): void {
backgroundDecodeRole = role; backgroundDecodeRole = role;
currentRigId = rigId || null; currentRigId = rigId || null;
if (currentRigId) loadBackgroundDecode(); if (currentRigId) loadBackgroundDecode();
startStatusPolling(); startStatusPolling();
} }
function setBackgroundDecodeRig(rigId) { function setBackgroundDecodeRig(rigId: string | null): void {
const nextRigId = rigId || null; const nextRigId = rigId || null;
if (nextRigId === currentRigId) return; if (nextRigId === currentRigId) return;
currentRigId = nextRigId; currentRigId = nextRigId;
@@ -33,44 +82,44 @@
loadBackgroundDecode(); loadBackgroundDecode();
} }
function apiGetConfig(rigId) { function apiGetConfig(rigId: string): Promise<BackgroundDecodeConfig> {
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) { return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json(); return r.json() as Promise<BackgroundDecodeConfig>;
}); });
} }
function apiPutConfig(rigId, config) { function apiPutConfig(rigId: string, config: BackgroundDecodeConfig): Promise<BackgroundDecodeConfig> {
return fetch("/background-decode/" + encodeURIComponent(rigId), { return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(config), body: JSON.stringify(config),
}).then(function (r) { }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json(); return r.json() as Promise<BackgroundDecodeConfig>;
}); });
} }
function apiResetConfig(rigId) { function apiResetConfig(rigId: string): Promise<BackgroundDecodeConfig> {
return fetch("/background-decode/" + encodeURIComponent(rigId), { return fetch("/background-decode/" + encodeURIComponent(rigId), {
method: "DELETE", method: "DELETE",
}).then(function (r) { }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json(); return r.json() as Promise<BackgroundDecodeConfig>;
}); });
} }
function apiGetStatus(rigId) { function apiGetStatus(rigId: string): Promise<BackgroundDecodeStatus> {
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) { return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json(); return r.json() as Promise<BackgroundDecodeStatus>;
}); });
} }
function apiGetBookmarks() { function apiGetBookmarks(): Promise<Bookmark[]> {
return fetch("/bookmarks").then(function (r) { return fetch("/bookmarks").then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status); if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
return r.json(); return r.json() as Promise<Bookmark[]>;
}); });
} }
@@ -79,35 +128,35 @@
if (!rigId) return; if (!rigId) return;
Promise.all([apiGetConfig(rigId), apiGetBookmarks()]) Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
.then(function ([config, bookmarks]) { .then(function ([config, bookmarks]) {
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] }; currentConfig = config;
bookmarkList = Array.isArray(bookmarks) ? bookmarks : []; bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
renderBackgroundDecode(); renderBackgroundDecode();
clearBgdDirty(); clearBgdDirty();
pollBackgroundDecodeStatus(); pollBackgroundDecodeStatus();
}) })
.catch(function (err) { .catch(function (err: unknown) {
console.error("background decode load failed", err); console.error("background decode load failed", err);
}); });
} }
function supportedBookmarks() { function supportedBookmarks(): Bookmark[] {
return bookmarkList.filter(function (bookmark) { return bookmarkList.filter(function (bookmark) {
return bookmarkDecoderKinds(bookmark).length > 0; return bookmarkDecoderKinds(bookmark).length > 0;
}); });
} }
function bookmarkDecoderKinds(bookmark) { function bookmarkDecoderKinds(bookmark: Bookmark): string[] {
var ids = bgdSupportedIds(); const ids = bgdSupportedIds();
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : []; const decoders = bookmark.decoders ?? [];
var explicit = decoders const explicit = decoders
.map(function (item) { return String(item || "").trim().toLowerCase(); }) .map(function (item) { return item.trim().toLowerCase(); })
.filter(function (item, index, arr) { .filter(function (item, index, arr) {
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index; return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
}); });
if (explicit.length > 0) return explicit; if (explicit.length > 0) return explicit;
// Fall back: infer from mode via mode-bound entries in the registry. // Fall back: infer from mode via mode-bound entries in the registry.
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase(); const mode = bookmark.mode.trim().toUpperCase();
return (window.decoderRegistry || []) return (bgdWindow.decoderRegistry || [])
.filter(function (d) { .filter(function (d) {
return d.activation === "mode_bound" && d.background_decode return d.activation === "mode_bound" && d.background_decode
&& d.active_modes.indexOf(mode) >= 0; && d.active_modes.indexOf(mode) >= 0;
@@ -119,13 +168,13 @@
if (!currentConfig) { if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
} }
setCheckbox("background-decode-enabled", !!currentConfig.enabled); setCheckbox("background-decode-enabled", currentConfig.enabled);
renderBookmarkChecklist(); renderBookmarkChecklist();
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled); const isControl = backgroundDecodeRole === "control" || hostState.authEnabled === false;
const panel = document.getElementById("background-decode-panel"); const panel = document.getElementById("background-decode-panel");
if (panel) { if (panel) {
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) { panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("input, select, button.sch-write").forEach(function (el) {
el.disabled = !isControl; el.disabled = !isControl;
}); });
} }
@@ -135,7 +184,7 @@
if (resetBtn) resetBtn.style.display = isControl ? "" : "none"; if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
} }
function renderBookmarkChecklist(filterText) { function renderBookmarkChecklist(filterText = ""): void {
const container = document.getElementById("bgd-bookmark-checklist"); const container = document.getElementById("bgd-bookmark-checklist");
if (!container) return; if (!container) return;
container.innerHTML = ""; container.innerHTML = "";
@@ -148,7 +197,7 @@
const filtered = filter const filtered = filter
? all.filter(function (bm) { ? all.filter(function (bm) {
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase(); const text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
return text.indexOf(filter) >= 0; return text.indexOf(filter) >= 0;
}) })
: all; : all;
@@ -161,22 +210,22 @@
} }
filtered.forEach(function (bookmark) { filtered.forEach(function (bookmark) {
var row = document.createElement("label"); const row = document.createElement("label");
row.className = "bgd-checklist-row"; row.className = "bgd-checklist-row";
var decoders = bookmarkDecoderKinds(bookmark); const decoders = bookmarkDecoderKinds(bookmark);
var checked = selectedIds.has(bookmark.id) ? " checked" : ""; const checked = selectedIds.has(bookmark.id) ? " checked" : "";
row.innerHTML = row.innerHTML =
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' + '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' + '<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>'; '<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
row.querySelector("input").addEventListener("change", function (e) { row.querySelector<HTMLInputElement>("input")?.addEventListener("change", function (e) {
onChecklistToggle(bookmark.id, e.target.checked); onChecklistToggle(bookmark.id, (e.currentTarget as HTMLInputElement).checked);
}); });
container.appendChild(row); container.appendChild(row);
}); });
} }
function onChecklistToggle(bookmarkId, checked) { function onChecklistToggle(bookmarkId: string, checked: boolean): void {
if (!currentConfig) { if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
} }
@@ -192,12 +241,12 @@
function saveBackgroundDecode() { function saveBackgroundDecode() {
const rigId = currentRigId; const rigId = currentRigId;
if (!rigId) return; if (!rigId) return;
const payload = { const payload: BackgroundDecodeConfig = {
remote: rigId, remote: rigId,
enabled: !!document.getElementById("background-decode-enabled").checked, enabled: (document.getElementById("background-decode-enabled") as HTMLInputElement | null)?.checked ?? false,
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [], bookmark_ids: currentConfig?.bookmark_ids.slice() ?? [],
}; };
const btn = document.getElementById("background-decode-save-btn"); const btn = document.getElementById("background-decode-save-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
apiPutConfig(rigId, payload) apiPutConfig(rigId, payload)
.then(function (saved) { .then(function (saved) {
@@ -205,30 +254,30 @@
renderBackgroundDecode(); renderBackgroundDecode();
clearBgdDirty(); clearBgdDirty();
pollBackgroundDecodeStatus(); pollBackgroundDecodeStatus();
showToast("Background decode saved."); showToast("Background decode saved.", false);
}) })
.catch(function (err) { .catch(function (err: unknown) {
showToast("Save failed: " + err.message, true); showToast(`Save failed: ${errorMessage(err)}`, true);
}) })
.finally(function () { .finally(function () {
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
}); });
} }
function resetBackgroundDecode() { async function resetBackgroundDecode() {
const rigId = currentRigId; const rigId = currentRigId;
if (!rigId) return; if (!rigId) return;
if (!confirm("Reset background decode configuration? This cannot be undone.")) return; if (!await bgdWindow.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
apiResetConfig(rigId) apiResetConfig(rigId)
.then(function (saved) { .then(function (saved) {
currentConfig = saved; currentConfig = saved;
renderBackgroundDecode(); renderBackgroundDecode();
clearBgdDirty(); clearBgdDirty();
pollBackgroundDecodeStatus(); pollBackgroundDecodeStatus();
showToast("Background decode reset."); showToast("Background decode reset.", false);
}) })
.catch(function (err) { .catch(function (err: unknown) {
showToast("Reset failed: " + err.message, true); showToast(`Reset failed: ${errorMessage(err)}`, true);
}); });
} }
@@ -245,18 +294,18 @@
.catch(function () {}); .catch(function () {});
} }
function renderStatus(status) { function renderStatus(status: BackgroundDecodeStatus): void {
const card = document.getElementById("background-decode-status-card"); const card = document.getElementById("background-decode-status-card");
if (!card) return; if (!card) return;
const entries = Array.isArray(status && status.entries) ? status.entries : []; const entries = status.entries ?? [];
if (!entries.length) { if (!entries.length) {
card.textContent = "No background decode bookmarks configured."; card.textContent = "No background decode bookmarks configured.";
return; return;
} }
const summary = []; const summary = [];
if (status.active_rig) { if (status.active_rig) {
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz)); if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2)); if (typeof status.sample_rate === "number" && Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
} else { } else {
summary.push("This rig is not currently selected for audio."); summary.push("This rig is not currently selected for audio.");
} }
@@ -265,7 +314,7 @@
entries.forEach(function (entry) { entries.forEach(function (entry) {
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark"; const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
const parts = []; const parts = [];
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz)); if (typeof entry.freq_hz === "number" && Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
if (entry.mode) parts.push(entry.mode); if (entry.mode) parts.push(entry.mode);
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) { if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
parts.push(entry.decoder_kinds.join("/").toUpperCase()); parts.push(entry.decoder_kinds.join("/").toUpperCase());
@@ -285,7 +334,7 @@
card.innerHTML = html; card.innerHTML = html;
} }
function prettyState(state) { function prettyState(state: string | undefined): string {
switch (state) { switch (state) {
case "active": return "\u2713 Active"; case "active": return "\u2713 Active";
case "out_of_span": return "\u25B3 Out of span"; case "out_of_span": return "\u25B3 Out of span";
@@ -301,40 +350,47 @@
} }
} }
function setCheckbox(id, value) { function setCheckbox(id: string, value: boolean): void {
const el = document.getElementById(id); const el = document.getElementById(id) as HTMLInputElement | null;
if (el) el.checked = !!value; if (el) el.checked = value;
} }
function formatFreq(hz) { function formatFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "--"; if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
return hz + " Hz"; return `${String(hz)} Hz`;
} }
function escHtml(value) { function escHtml(value: unknown): string {
return String(value == null ? "" : value) const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: "";
return text
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function markBgdDirty() { function markBgdDirty() {
if (bgdDirty) return; if (bgdDirty) return;
bgdDirty = true; bgdDirty = true;
var btn = document.getElementById("background-decode-save-btn"); const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.add("sch-dirty"); if (btn) btn.classList.add("sch-dirty");
} }
function clearBgdDirty() { function clearBgdDirty() {
bgdDirty = false; bgdDirty = false;
var btn = document.getElementById("background-decode-save-btn"); const btn = document.getElementById("background-decode-save-btn");
if (btn) btn.classList.remove("sch-dirty"); if (btn) btn.classList.remove("sch-dirty");
} }
function showToast(msg, isError) { function showToast(msg: string, isError: boolean): void {
const el = document.getElementById("background-decode-toast"); const el = document.getElementById("background-decode-toast");
if (!el) return; if (!el) return;
el.textContent = msg; el.textContent = msg;
@@ -349,9 +405,9 @@
if (!currentConfig) { if (!currentConfig) {
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
} }
var ids = supportedBookmarks().map(function (bm) { return bm.id; }); const ids = supportedBookmarks().map(function (bm) { return bm.id; });
currentConfig.bookmark_ids = ids; currentConfig.bookmark_ids = ids;
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value); renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
markBgdDirty(); markBgdDirty();
} }
@@ -360,12 +416,12 @@
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
} }
currentConfig.bookmark_ids = []; currentConfig.bookmark_ids = [];
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value); renderBookmarkChecklist((document.getElementById("bgd-bookmark-filter") as HTMLInputElement | null)?.value);
markBgdDirty(); markBgdDirty();
} }
function wireBackgroundDecodeEvents() { function wireBackgroundDecodeEvents() {
const filterInput = document.getElementById("bgd-bookmark-filter"); const filterInput = document.getElementById("bgd-bookmark-filter") as (HTMLInputElement & WiredElement) | null;
if (filterInput && !filterInput._wired) { if (filterInput && !filterInput._wired) {
filterInput._wired = true; filterInput._wired = true;
filterInput.addEventListener("input", function () { filterInput.addEventListener("input", function () {
@@ -373,38 +429,42 @@
}); });
} }
const enabledCb = document.getElementById("background-decode-enabled"); const enabledCb = document.getElementById("background-decode-enabled") as (HTMLInputElement & WiredElement) | null;
if (enabledCb && !enabledCb._wired) { if (enabledCb && !enabledCb._wired) {
enabledCb._wired = true; enabledCb._wired = true;
enabledCb.addEventListener("change", function () { markBgdDirty(); }); enabledCb.addEventListener("change", function () { markBgdDirty(); });
} }
const selectAllBtn = document.getElementById("bgd-select-all-btn"); const selectAllBtn = document.getElementById("bgd-select-all-btn") as WiredElement | null;
if (selectAllBtn && !selectAllBtn._wired) { if (selectAllBtn && !selectAllBtn._wired) {
selectAllBtn._wired = true; selectAllBtn._wired = true;
selectAllBtn.addEventListener("click", selectAllBookmarks); selectAllBtn.addEventListener("click", selectAllBookmarks);
} }
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn"); const deselectAllBtn = document.getElementById("bgd-deselect-all-btn") as WiredElement | null;
if (deselectAllBtn && !deselectAllBtn._wired) { if (deselectAllBtn && !deselectAllBtn._wired) {
deselectAllBtn._wired = true; deselectAllBtn._wired = true;
deselectAllBtn.addEventListener("click", deselectAllBookmarks); deselectAllBtn.addEventListener("click", deselectAllBookmarks);
} }
const saveBtn = document.getElementById("background-decode-save-btn"); const saveBtn = document.getElementById("background-decode-save-btn") as WiredElement | null;
if (saveBtn && !saveBtn._wired) { if (saveBtn && !saveBtn._wired) {
saveBtn._wired = true; saveBtn._wired = true;
saveBtn.addEventListener("click", saveBackgroundDecode); saveBtn.addEventListener("click", saveBackgroundDecode);
} }
const resetBtn = document.getElementById("background-decode-reset-btn"); const resetBtn = document.getElementById("background-decode-reset-btn") as WiredElement | null;
if (resetBtn && !resetBtn._wired) { if (resetBtn && !resetBtn._wired) {
resetBtn._wired = true; resetBtn._wired = true;
resetBtn.addEventListener("click", resetBackgroundDecode); resetBtn.addEventListener("click", () => { void resetBackgroundDecode(); });
} }
} }
window.initBackgroundDecode = initBackgroundDecode; bgdWindow.trx ??= {};
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents; bgdWindow.trx.modules ??= {};
window.setBackgroundDecodeRig = setBackgroundDecodeRig; bgdWindow.trx.modules.backgroundDecode = {
initialize: initBackgroundDecode,
wireEvents: wireBackgroundDecodeEvents,
setRig: setBackgroundDecodeRig,
};
})(); })();
@@ -2,83 +2,139 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // 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
* during initialization if that contract is broken. */
interface Bookmark {
id: string;
name: string;
freq_hz: number;
mode: string;
bandwidth_hz?: number | null;
locator?: string | null;
category?: string | null;
comment?: string | null;
decoders?: string[];
scope?: string;
}
interface BookmarkService {
readonly overlayList: readonly Bookmark[];
readonly overlayRevision: number;
refreshOverlay(): Promise<void>;
invalidateColors(): void;
apply(bookmark: Bookmark): void;
formatFrequency(frequencyHz: number): string;
fetch(categoryFilter: string): Promise<void>;
populateScopePicker(): void;
}
interface VirtualChannelService {
interceptMode(mode: string): Promise<boolean>;
interceptBandwidth(bandwidthHz: number): Promise<boolean>;
takeSchedulerControl(): Promise<void>;
}
interface BookmarkBridge {
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;
}
type BookmarkElement = HTMLElement & HTMLInputElement & HTMLSelectElement;
const bridge = window as unknown as BookmarkBridge;
function bmEl(id: string): BookmarkElement {
const element = document.getElementById(id);
if (!element) throw new Error(`Missing bookmark element #${id}`);
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);
}
// --- Bookmarks Tab --- // --- Bookmarks Tab ---
/** Current bookmark scope: "general" or a rig remote name. */ /** Current bookmark scope: "general" or a rig remote name. */
let bmScope = "general"; let bmScope = "general";
/** Build the ?scope= query string for a given or current bookmark scope. */ /** Build the ?scope= query string for a given or current bookmark scope. */
function bmScopeParam(prefix, scope) { function bmScopeParam(prefix: boolean, scope?: string | null): string {
const sep = prefix ? "&" : "?"; const sep = prefix ? "&" : "?";
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope); return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
} }
var bmList = []; let bmList: Bookmark[] = [];
var bmRevision = 0;
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */ /** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
var bmOverlayList = []; let bmOverlayList: Bookmark[] = [];
var bmOverlayRevision = 0; let bmOverlayRevision = 0;
let bmFilteredList = []; let bmFilteredList: Bookmark[] = [];
let bmEditId = null; let bmEditScope: string | null = null;
let bmEditScope = null;
let bmCurrentPage = 1; let bmCurrentPage = 1;
const BM_PAGE_SIZE = 25; const BM_PAGE_SIZE = 25;
const bmSelected = new Set(); const bmSelected = new Set<string>();
function bmFmtFreq(hz) { function bmFmtFreq(hz: number): string {
if (!Number.isFinite(hz) || hz <= 0) return "--"; if (!Number.isFinite(hz) || hz <= 0) return "--";
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz"; if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
return hz + "\u202fHz"; return `${hz}\u202fHz`;
} }
function bmEsc(str) { function bmEsc(str: unknown): string {
const d = document.createElement("div"); const d = document.createElement("div");
d.appendChild(document.createTextNode(String(str))); d.appendChild(document.createTextNode(String(str)));
return d.innerHTML; return d.innerHTML;
} }
function bmCanControl() { function bmCanControl() {
return ( return !hostState.authEnabled || hostState.authRole === "control";
(typeof authEnabled !== "undefined" && !authEnabled) ||
(typeof authRole !== "undefined" && authRole === "control")
);
} }
// Show/hide the Add Bookmark / Select All buttons based on the current auth role. // Show/hide the Add Bookmark / Select All buttons based on the current auth role.
function bmSyncAccess() { function bmSyncAccess() {
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
const addBtn = document.getElementById("bm-add-btn"); const addBtn = bmEl("bm-add-btn");
const selectAllBtn = document.getElementById("bm-select-all-btn"); const selectAllBtn = bmEl("bm-select-all-btn");
if (addBtn) addBtn.style.display = canCtrl ? "" : "none"; if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
} }
/** The listing scope: always the active rig (to merge general + rig bookmarks). */ /** The listing scope: always the active rig (to merge general + rig bookmarks). */
function bmListScope() { function bmListScope() {
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null; return hostState.lastActiveRigId || "general";
return rig || "general";
} }
async function bmFetchOverlay() { async function bmFetchOverlay() {
const overlayScope = bmListScope(); const overlayScope = bmListScope();
try { try {
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope)); const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
bmOverlayList = await resp.json(); bmOverlayList = await resp.json() as Bookmark[];
} catch (e) { } catch (e) {
console.error("Failed to fetch overlay bookmarks:", e); console.error("Failed to fetch overlay bookmarks:", e);
bmOverlayList = []; bmOverlayList = [];
} }
bmOverlayRevision++; bmOverlayRevision++;
if (typeof window.syncBookmarkMapLocators === "function") { if (typeof bridge.syncBookmarkMapLocators === "function") {
window.syncBookmarkMapLocators(bmOverlayList); bridge.syncBookmarkMapLocators(bmOverlayList);
} }
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw(); hostCore.scheduleSpectrumDraw();
} }
async function bmFetch(categoryFilter) { async function bmFetch(categoryFilter: string): Promise<void> {
let url = "/bookmarks"; let url = "/bookmarks";
let hasQuery = false; let hasQuery = false;
if (categoryFilter && categoryFilter !== "") { if (categoryFilter && categoryFilter !== "") {
@@ -89,26 +145,25 @@ async function bmFetch(categoryFilter) {
const overlayPromise = bmFetchOverlay(); const overlayPromise = bmFetchOverlay();
try { try {
const resp = await fetch(url); const resp = await fetch(url);
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
bmList = await resp.json(); bmList = await resp.json() as Bookmark[];
} catch (e) { } catch (e) {
console.error("Failed to fetch bookmarks:", e); console.error("Failed to fetch bookmarks:", e);
bmList = []; bmList = [];
} }
bmRevision++;
bmSelected.clear(); bmSelected.clear();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
bmSyncAccess(); bmSyncAccess();
bmApplyFilters(); bmApplyFilters();
bmRefreshCategoryFilter(categoryFilter); void bmRefreshCategoryFilter(categoryFilter);
await overlayPromise; await overlayPromise;
} }
function bmApplyFilters() { function bmApplyFilters() {
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase(); const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase();
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase(); const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase();
let filtered = modeFilter let filtered = modeFilter
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter) ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter)
: bmList; : bmList;
filtered = text filtered = text
? filtered.filter((bm) => ? filtered.filter((bm) =>
@@ -123,14 +178,14 @@ function bmApplyFilters() {
bmRender(filtered); bmRender(filtered);
} }
async function bmRefreshCategoryFilter(keepValue) { async function bmRefreshCategoryFilter(keepValue: string): Promise<void> {
const sel = document.getElementById("bm-category-filter"); const sel = bmEl("bm-category-filter");
const modeSel = document.getElementById("bm-mode-filter"); const modeSel = bmEl("bm-mode-filter");
if (!sel && !modeSel) return; if (!sel && !modeSel) return;
try { try {
const resp = await fetch("/bookmarks" + bmScopeParam(false)); const resp = await fetch("/bookmarks" + bmScopeParam(false));
if (!resp.ok) return; if (!resp.ok) return;
const all = await resp.json(); const all = await resp.json() as Bookmark[];
if (sel) { if (sel) {
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort(); const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
while (sel.options.length > 1) sel.remove(1); while (sel.options.length > 1) sel.remove(1);
@@ -144,7 +199,7 @@ async function bmRefreshCategoryFilter(keepValue) {
} }
if (modeSel) { if (modeSel) {
const keepMode = modeSel.value; const keepMode = modeSel.value;
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort(); const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
while (modeSel.options.length > 1) modeSel.remove(1); while (modeSel.options.length > 1) modeSel.remove(1);
modes.forEach((mode) => { modes.forEach((mode) => {
const opt = document.createElement("option"); const opt = document.createElement("option");
@@ -154,17 +209,17 @@ async function bmRefreshCategoryFilter(keepValue) {
}); });
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode; if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
} }
} catch (_) {} } catch {}
} }
function bmRender(list) { function bmRender(list: Bookmark[]): void {
const tbody = document.getElementById("bm-tbody"); const tbody = bmEl("bm-tbody");
const emptyEl = document.getElementById("bm-empty"); const emptyEl = bmEl("bm-empty");
const paginatorEl = document.getElementById("bm-paginator"); const paginatorEl = bmEl("bm-paginator");
const pageSummaryEl = document.getElementById("bm-page-summary"); const pageSummaryEl = bmEl("bm-page-summary");
const pageIndicatorEl = document.getElementById("bm-page-indicator"); const pageIndicatorEl = bmEl("bm-page-indicator");
const prevBtn = document.getElementById("bm-page-prev"); const prevBtn = bmEl("bm-page-prev");
const nextBtn = document.getElementById("bm-page-next"); const nextBtn = bmEl("bm-page-next");
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ""; tbody.innerHTML = "";
@@ -222,7 +277,7 @@ function bmRender(list) {
if (nextBtn) nextBtn.disabled = page >= totalPages; if (nextBtn) nextBtn.disabled = page >= totalPages;
} }
function bmChangePage(delta) { function bmChangePage(delta: number): void {
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages); const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
if (nextPage === bmCurrentPage) return; if (nextPage === bmCurrentPage) return;
@@ -231,30 +286,30 @@ function bmChangePage(delta) {
} }
// Read decoder checkboxes and return an array of selected decoder names. // Read decoder checkboxes and return an array of selected decoder names.
function bmReadDecoders() { function bmReadDecoders(): string[] {
return (window.decoderRegistry || []) return hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked) .filter(d => bmOptionalEl("bm-dec-" + d.id)?.checked)
.map(d => d.id); .map(d => d.id);
} }
// Set decoder checkboxes to match the given array. // Set decoder checkboxes to match the given array.
function bmWriteDecoders(decoders) { function bmWriteDecoders(decoders: readonly string[]): void {
const set = new Set(decoders || []); const set = new Set(decoders || []);
(window.decoderRegistry || []) hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.forEach(d => { .forEach(d => {
const el = document.getElementById("bm-dec-" + d.id); const el = bmOptionalEl("bm-dec-" + d.id);
if (el) el.checked = set.has(d.id); if (el) el.checked = set.has(d.id);
}); });
} }
// Build decoder checkboxes dynamically from the registry. // Build decoder checkboxes dynamically from the registry.
function bmBuildDecoderCheckboxes() { function bmBuildDecoderCheckboxes() {
const container = document.getElementById("bm-decoder-checkboxes"); const container = bmEl("bm-decoder-checkboxes");
if (!container) return; if (!container) return;
container.innerHTML = ""; container.innerHTML = "";
(window.decoderRegistry || []) hostState.decoderRegistry
.filter(d => d.bookmark_selectable) .filter(d => d.bookmark_selectable)
.forEach(d => { .forEach(d => {
const label = document.createElement("label"); const label = document.createElement("label");
@@ -264,75 +319,76 @@ function bmBuildDecoderCheckboxes() {
}); });
} }
function bmOpenForm(bm) { function bmOpenForm(bm: Bookmark | null): void {
const wrap = document.getElementById("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (!wrap) return; if (!wrap) return;
bmEditId = bm ? bm.id : null;
bmEditScope = bm ? (bm.scope || bmScope) : null; bmEditScope = bm ? (bm.scope || bmScope) : null;
// Rebuild decoder checkboxes from registry (handles race where registry // Rebuild decoder checkboxes from registry (handles race where registry
// loaded after initial build). // loaded after initial build).
bmBuildDecoderCheckboxes(); bmBuildDecoderCheckboxes();
document.getElementById("bm-id").value = bm ? bm.id : ""; bmEl("bm-id").value = bm ? bm.id : "";
document.getElementById("bm-name").value = bm ? bm.name : ""; bmEl("bm-name").value = bm ? bm.name : "";
document.getElementById("bm-freq").value = bm ? bm.freq_hz : ""; bmEl("bm-freq").value = bm ? String(bm.freq_hz) : "";
document.getElementById("bm-mode").value = bm ? bm.mode : ""; bmEl("bm-mode").value = bm ? bm.mode : "";
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : ""; bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : "";
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : ""; bmEl("bm-locator").value = bm ? (bm.locator || "") : "";
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : ""; bmEl("bm-category-input").value = bm ? (bm.category || "") : "";
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : ""; bmEl("bm-comment").value = bm ? (bm.comment || "") : "";
bmWriteDecoders(bm ? bm.decoders : []); bmWriteDecoders(bm?.decoders ?? []);
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
wrap.style.display = "flex"; wrap.style.display = "flex";
document.getElementById("bm-name").focus(); bmEl("bm-name").focus();
} }
function bmCloseForm() { function bmCloseForm() {
const wrap = document.getElementById("bm-form-wrap"); const wrap = bmEl("bm-form-wrap");
if (wrap) wrap.style.display = "none"; if (wrap) wrap.style.display = "none";
bmEditId = null;
} }
function bmPrefillFromStatus() { function bmPrefillFromStatus() {
// Use globals maintained by app.js (updated by SSE stream) // Read live rig state from the host contract (updated by the SSE stream).
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) { const freqHz = hostState.lastFreqHz;
document.getElementById("bm-freq").value = Math.round(lastFreqHz); if (freqHz != null && Number.isFinite(freqHz)) {
bmEl("bm-freq").value = String(Math.round(freqHz));
} }
if (typeof lastModeName === "string" && lastModeName) { if (hostState.lastModeName) {
document.getElementById("bm-mode").value = lastModeName; bmEl("bm-mode").value = hostState.lastModeName;
} }
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) { if (hostState.currentBandwidthHz > 0) {
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz); bmEl("bm-bw").value = String(Math.round(hostState.currentBandwidthHz));
} }
// Prefill decoder checkboxes from current toggle button state. // Prefill decoder checkboxes from current toggle button state.
const activeDecoders = (window.decoderRegistry || []) const activeDecoders = hostState.decoderRegistry
.filter(d => d.bookmark_selectable && d.activation === "toggle") .filter(d => d.bookmark_selectable && d.activation === "toggle")
.filter(d => { .filter(d => bmOptionalEl(d.id + "-decode-toggle-btn")?.dataset.enabled === "true")
const btn = document.getElementById(d.id + "-decode-toggle-btn");
return btn && btn.dataset.enabled === "true";
})
.map(d => d.id); .map(d => d.id);
bmWriteDecoders(activeDecoders); bmWriteDecoders(activeDecoders);
} }
async function bmSave(e) { async function bmSave(e: Event): Promise<void> {
e.preventDefault(); e.preventDefault();
const id = document.getElementById("bm-id").value; const id = bmEl("bm-id").value;
const name = document.getElementById("bm-name").value.trim(); const name = bmEl("bm-name").value.trim();
const freqStr = document.getElementById("bm-freq").value; const freqStr = bmEl("bm-freq").value;
const freq_hz = parseInt(freqStr, 10); const freq_hz = parseInt(freqStr, 10);
const mode = document.getElementById("bm-mode").value.trim(); const mode = bmEl("bm-mode").value.trim();
const bwStr = document.getElementById("bm-bw").value; const bwStr = bmEl("bm-bw").value;
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null; const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
const locator = document.getElementById("bm-locator").value.trim().toUpperCase(); const locator = bmEl("bm-locator").value.trim().toUpperCase();
const category = document.getElementById("bm-category-input").value.trim(); const category = bmEl("bm-category-input").value.trim();
const comment = document.getElementById("bm-comment").value.trim(); const comment = bmEl("bm-comment").value.trim();
const decoders = bmReadDecoders(); const decoders = bmReadDecoders();
const formError = bmEl("bm-form-error");
if (formError) formError.textContent = "";
if (!name || !Number.isFinite(freq_hz) || !mode) { if (!name || !Number.isFinite(freq_hz) || !mode) {
alert("Name, Frequency, and Mode are required."); if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
const invalid = !name ? bmEl("bm-name")
: !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode");
invalid?.focus();
return; return;
} }
@@ -367,90 +423,75 @@ async function bmSave(e) {
if (resp.status === 409) { if (resp.status === 409) {
throw new Error("A bookmark for that frequency already exists."); throw new Error("A bookmark for that frequency already exists.");
} }
throw new Error(text || "HTTP " + resp.status); throw new Error(text || `HTTP ${resp.status}`);
} }
bmCloseForm(); bmCloseForm();
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to save bookmark:", err); console.error("Failed to save bookmark:", err);
alert("Failed to save bookmark: " + err.message); if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err);
bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" });
} }
} }
async function bmDelete(id) { async function bmDelete(id: string): Promise<void> {
if (!confirm("Delete this bookmark?")) return; if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
const bm = bmList.find((b) => b.id === id); const bm = bmList.find((b) => b.id === id);
const scope = bm ? bm.scope : undefined; const scope = bm ? bm.scope : undefined;
try { try {
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), { const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
method: "DELETE", method: "DELETE",
}); });
if (!resp.ok) throw new Error("HTTP " + resp.status); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmark:", err); console.error("Failed to delete bookmark:", err);
alert("Failed to delete bookmark: " + err.message); bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" });
} }
} }
async function bmApply(bm) { function bmApply(bm: Bookmark): void {
try { try {
// --- Optimistic UI updates (instant, before any network round-trips) --- // --- Optimistic UI updates (instant, before any network round-trips) ---
if (typeof modeEl !== "undefined" && modeEl) { const modeEl = document.getElementById("mode") as HTMLSelectElement | null;
modeEl.value = String(bm.mode || "").toUpperCase(); if (modeEl) {
modeEl.value = (bm.mode || "").toUpperCase();
hostCore.syncModePicker();
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
if (typeof currentBandwidthHz !== "undefined") { hostState.currentBandwidthHz = bm.bandwidth_hz;
currentBandwidthHz = bm.bandwidth_hz; hostCore.syncBandwidthInput(bm.bandwidth_hz);
}
window.currentBandwidthHz = bm.bandwidth_hz;
if (typeof syncBandwidthInput === "function") {
syncBandwidthInput(bm.bandwidth_hz);
}
} }
if (typeof applyLocalTunedFrequency === "function") { // Set optimistic guard before applying so SSE cannot snap back.
// Set optimistic guard before applying so SSE cannot snap back. hostCore.armOptimisticFrequency(bm.freq_hz);
if (typeof _freqOptimisticSeq !== "undefined") { // Force display so the BW overlay is repositioned even when freq is unchanged.
++_freqOptimisticSeq; hostCore.applyLocalTunedFrequency(bm.freq_hz, true);
_freqOptimisticHz = bm.freq_hz; if (hostState.lastSpectrumData) {
} hostCore.scheduleSpectrumDraw();
// Force display so the BW overlay is repositioned even when freq is unchanged.
applyLocalTunedFrequency(bm.freq_hz, true);
}
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
scheduleSpectrumDraw();
} }
// Take scheduler control up front, then apply mode before bandwidth so a // Take scheduler control up front, then apply mode before bandwidth so a
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz. // late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
const tunePromise = (async () => { const tunePromise = (async () => {
if (typeof vchanTakeSchedulerControl === "function") { await bridge.trx.modules.vchan?.takeSchedulerControl();
await vchanTakeSchedulerControl();
}
const onVirtual = typeof vchanInterceptMode === "function" const onVirtual = await bridge.trx.modules.vchan?.interceptMode(bm.mode) ?? false;
&& await vchanInterceptMode(bm.mode);
if (!onVirtual) { if (!onVirtual) {
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); await hostCore.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
} }
if (bm.bandwidth_hz) { if (bm.bandwidth_hz) {
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function" const bwHandledByVchan =
&& await vchanInterceptBandwidth(bm.bandwidth_hz); await bridge.trx.modules.vchan?.interceptBandwidth(bm.bandwidth_hz) ?? false;
if (!bwHandledByVchan) { if (!bwHandledByVchan) {
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz); await hostCore.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`);
} }
} }
// setRigFrequency is wrapped by vchan.js to redirect to the channel API // setRigFrequency redirects to the channel API when a virtual channel is
// when on a virtual channel, so this call works correctly in both cases. // active. It repeats the optimistic update applied above, which is a
// It also does its own optimistic update (applyLocalTunedFrequency) but // no-op because the value is unchanged.
// that's a no-op since we already set the same value above. hostCore.setRigFrequency(bm.freq_hz);
if (typeof setRigFrequency === "function") {
await setRigFrequency(bm.freq_hz);
} else {
await postPath("/set_freq?hz=" + bm.freq_hz);
}
})(); })();
// Decoder toggles — fire-and-forget. // Decoder toggles — fire-and-forget.
// - Decoders incompatible with the new mode are always turned off // - Decoders incompatible with the new mode are always turned off
@@ -460,18 +501,19 @@ async function bmApply(bm) {
// alone. // alone.
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
const modeUp = (bm.mode || "").toUpperCase(); const modeUp = (bm.mode || "").toUpperCase();
const allToggleDecoders = (window.decoderRegistry || []).filter(d => const allToggleDecoders = hostState.decoderRegistry.filter(d =>
d.activation === "toggle" d.activation === "toggle"
); );
const decoderPromise = allToggleDecoders.length ? (async () => { const decoderPromise = allToggleDecoders.length ? (async () => {
let statusUrl = "/status"; let statusUrl = "/status";
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) { const rigId = hostState.lastActiveRigId;
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId); if (rigId) {
statusUrl += "?remote=" + encodeURIComponent(rigId);
} }
const statusResp = await fetch(statusUrl); const statusResp = await fetch(statusUrl);
if (!statusResp.ok) return; if (!statusResp.ok) return;
const st = await statusResp.json(); const st = await statusResp.json() as Record<string, unknown>;
const toggles = []; const toggles: Promise<unknown>[] = [];
for (const d of allToggleDecoders) { for (const d of allToggleDecoders) {
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled"; const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
const currentlyOn = !!st[statusKey]; const currentlyOn = !!st[statusKey];
@@ -482,41 +524,52 @@ async function bmApply(bm) {
// Always disable decoders that don't apply to the new mode. // Always disable decoders that don't apply to the new mode.
wanted = false; wanted = false;
} else if (hasDecoders) { } else if (hasDecoders) {
wanted = bm.decoders.includes(d.id); wanted = bm.decoders?.includes(d.id) ?? false;
} else { } else {
// Mode-compatible and no bookmark selection: leave as-is. // Mode-compatible and no bookmark selection: leave as-is.
wanted = currentlyOn; wanted = currentlyOn;
} }
if (wanted !== currentlyOn) { if (wanted !== currentlyOn) {
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); toggles.push(hostCore.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
} }
} }
if (toggles.length) await Promise.all(toggles); if (toggles.length) await Promise.all(toggles);
})() : Promise.resolve(); })() : Promise.resolve();
// Don't await — let the network calls settle in the background. // Don't await — let the network calls settle in the background.
// Errors are logged but don't block the UI. // Errors are logged but don't block the UI.
Promise.all([tunePromise, decoderPromise]).catch( void Promise.all([tunePromise, decoderPromise]).catch((error: unknown) => {
(err) => console.error("Bookmark apply background error:", err) console.error("Bookmark apply background error:", error);
); });
} catch (err) { } catch (err) {
console.error("Failed to apply bookmark:", err); console.error("Failed to apply bookmark:", err);
} }
} }
bridge.trx.modules.bookmarks = {
get overlayList() { return bmOverlayList; },
get overlayRevision() { return bmOverlayRevision; },
refreshOverlay: bmFetchOverlay,
invalidateColors() { bmOverlayRevision += 1; },
apply: bmApply,
formatFrequency: bmFmtFreq,
fetch: bmFetch,
populateScopePicker: bmPopulateScopePicker,
};
function bmUpdateSelectionUi() { function bmUpdateSelectionUi() {
const count = bmSelected.size; const count = bmSelected.size;
const canCtrl = bmCanControl(); const canCtrl = bmCanControl();
const visible = count > 0 && canCtrl; const visible = count > 0 && canCtrl;
const btn = document.getElementById("bm-del-selected-btn"); const btn = bmEl("bm-del-selected-btn");
const countEl = document.getElementById("bm-del-selected-count"); const countEl = bmEl("bm-del-selected-count");
if (btn) btn.style.display = visible ? "" : "none"; if (btn) btn.style.display = visible ? "" : "none";
if (countEl) countEl.textContent = count; if (countEl) countEl.textContent = String(count);
const moveWrap = document.getElementById("bm-move-selected-wrap"); const moveWrap = bmEl("bm-move-selected-wrap");
const moveCountEl = document.getElementById("bm-move-selected-count"); const moveCountEl = bmEl("bm-move-selected-count");
if (moveWrap) moveWrap.style.display = visible ? "" : "none"; if (moveWrap) moveWrap.style.display = visible ? "" : "none";
if (moveCountEl) moveCountEl.textContent = count; if (moveCountEl) moveCountEl.textContent = String(count);
if (visible) bmPopulateMoveTarget(); if (visible) bmPopulateMoveTarget();
const selectAllBtn = document.getElementById("bm-select-all-btn"); const selectAllBtn = bmEl("bm-select-all-btn");
if (selectAllBtn && bmCanControl()) { if (selectAllBtn && bmCanControl()) {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All"; selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
@@ -525,10 +578,10 @@ function bmUpdateSelectionUi() {
/** Populate the move-target dropdown with all scopes except the current one. */ /** Populate the move-target dropdown with all scopes except the current one. */
function bmPopulateMoveTarget() { function bmPopulateMoveTarget() {
const sel = document.getElementById("bm-move-target"); const sel = bmEl("bm-move-target");
if (!sel) return; if (!sel) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : []; const rigIds = hostState.lastRigIds;
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {}; const displayNames = hostState.lastRigDisplayNames;
const prev = sel.value; const prev = sel.value;
sel.innerHTML = ""; sel.innerHTML = "";
if (bmScope !== "general") { if (bmScope !== "general") {
@@ -552,13 +605,18 @@ function bmPopulateMoveTarget() {
async function bmMoveSelected() { async function bmMoveSelected() {
const ids = Array.from(bmSelected); const ids = Array.from(bmSelected);
if (ids.length === 0) return; if (ids.length === 0) return;
const target = document.getElementById("bm-move-target")?.value; const target = bmEl("bm-move-target")?.value;
if (!target) return; if (!target) return;
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target; const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target;
if (!confirm(`Move ${ids.length} bookmark${ids.length > 1 ? "s" : ""} to "${targetLabel}"?`)) return; if (!await bridge.trxUi.confirm({
title: "Move selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
confirmLabel: "Move",
danger: false,
})) return;
try { try {
// Group selected IDs by their owning scope (skip if already in target). // Group selected IDs by their owning scope (skip if already in target).
const byScope = {}; const byScope: Record<string, string[]> = {};
for (const id of ids) { for (const id of ids) {
const bm = bmList.find((b) => b.id === id); const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope; const scope = bm?.scope || bmScope;
@@ -570,21 +628,21 @@ async function bmMoveSelected() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds, to: target }), body: JSON.stringify({ ids: scopeIds, to: target }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); }) }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
)); ));
bmSelected.clear(); bmSelected.clear();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to move bookmarks:", err); console.error("Failed to move bookmarks:", err);
alert("Failed to move bookmarks: " + err.message); bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" });
} }
} }
function bmSyncSelectAllCheckbox() { function bmSyncSelectAllCheckbox() {
const selectAll = document.getElementById("bm-select-all"); const selectAll = bmEl("bm-select-all");
if (!selectAll) return; if (!selectAll) return;
const checkboxes = document.querySelectorAll(".bm-row-sel"); const checkboxes = document.querySelectorAll<HTMLInputElement>(".bm-row-sel");
if (checkboxes.length === 0) { if (checkboxes.length === 0) {
selectAll.checked = false; selectAll.checked = false;
selectAll.indeterminate = false; selectAll.indeterminate = false;
@@ -598,10 +656,14 @@ function bmSyncSelectAllCheckbox() {
async function bmDeleteSelected() { async function bmDeleteSelected() {
const ids = Array.from(bmSelected); const ids = Array.from(bmSelected);
if (ids.length === 0) return; if (ids.length === 0) return;
if (!confirm(`Delete ${ids.length} selected bookmark${ids.length > 1 ? "s" : ""}?`)) return; if (!await bridge.trxUi.confirm({
title: "Delete selected bookmarks?",
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
confirmLabel: "Delete",
})) return;
try { try {
// Group selected IDs by their owning scope. // Group selected IDs by their owning scope.
const byScope = {}; const byScope: Record<string, string[]> = {};
for (const id of ids) { for (const id of ids) {
const bm = bmList.find((b) => b.id === id); const bm = bmList.find((b) => b.id === id);
const scope = bm?.scope || bmScope; const scope = bm?.scope || bmScope;
@@ -612,23 +674,23 @@ async function bmDeleteSelected() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: scopeIds }), body: JSON.stringify({ ids: scopeIds }),
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); }) }).then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); })
)); ));
bmSelected.clear(); bmSelected.clear();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
await bmFetch(document.getElementById("bm-category-filter").value); await bmFetch(bmEl("bm-category-filter").value);
} catch (err) { } catch (err) {
console.error("Failed to delete bookmarks:", err); console.error("Failed to delete bookmarks:", err);
alert("Failed to delete bookmarks: " + err.message); bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" });
} }
} }
/** Populate the scope picker with "General" + one option per rig. */ /** Populate the scope picker with "General" + one option per rig. */
function bmPopulateScopePicker() { function bmPopulateScopePicker() {
const picker = document.getElementById("bm-scope-picker"); const picker = bmEl("bm-scope-picker");
if (!picker) return; if (!picker) return;
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : []; const rigIds = hostState.lastRigIds;
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {}; const displayNames = hostState.lastRigDisplayNames;
// Preserve current selection if still valid. // Preserve current selection if still valid.
const prev = picker.value; const prev = picker.value;
while (picker.options.length > 1) picker.remove(1); while (picker.options.length > 1) picker.remove(1);
@@ -655,63 +717,61 @@ function bmPopulateScopePicker() {
// Build decoder checkboxes from registry. The registry is fetched async // Build decoder checkboxes from registry. The registry is fetched async
// so we rebuild once it arrives to ensure checkboxes are present. // so we rebuild once it arrives to ensure checkboxes are present.
bmBuildDecoderCheckboxes(); bmBuildDecoderCheckboxes();
if (typeof window.onDecoderRegistryReady === "function") { hostCore.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
}
// Scope picker // Scope picker
bmPopulateScopePicker(); bmPopulateScopePicker();
const scopePicker = document.getElementById("bm-scope-picker"); const scopePicker = bmEl("bm-scope-picker");
if (scopePicker) { if (scopePicker) {
scopePicker.addEventListener("change", (e) => { scopePicker.addEventListener("change", (e) => {
bmScope = e.target.value; bmScope = (e.currentTarget as HTMLSelectElement).value;
bmFetch(document.getElementById("bm-category-filter")?.value || ""); void bmFetch(bmEl("bm-category-filter").value || "");
}); });
} }
// Refresh list and sync access when the Bookmarks tab is activated // Refresh list and sync access when the Bookmarks tab is activated
document.querySelector(".tab-bar").addEventListener("click", (e) => { document.querySelector(".tab-bar")?.addEventListener("click", (e) => {
const btn = e.target.closest('.tab[data-tab="bookmarks"]'); const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null;
if (!btn) return; if (!btn) return;
bmFetch(document.getElementById("bm-category-filter").value); void bmFetch(bmEl("bm-category-filter").value);
}); });
// Add Bookmark button — open form and prefill from current rig state // Add Bookmark button — open form and prefill from current rig state
document.getElementById("bm-add-btn").addEventListener("click", () => { bmEl("bm-add-btn").addEventListener("click", () => {
bmOpenForm(null); bmOpenForm(null);
bmPrefillFromStatus(); bmPrefillFromStatus();
}); });
// Category filter dropdown // Category filter dropdown
document.getElementById("bm-category-filter").addEventListener("change", (e) => { bmEl("bm-category-filter").addEventListener("change", (e) => {
bmFetch(e.target.value); void bmFetch((e.currentTarget as HTMLSelectElement).value);
}); });
// Mode filter dropdown (client-side, no re-fetch) // Mode filter dropdown (client-side, no re-fetch)
document.getElementById("bm-mode-filter").addEventListener("change", () => { bmEl("bm-mode-filter").addEventListener("change", () => {
bmApplyFilters(); bmApplyFilters();
}); });
// Text search filter (client-side, no re-fetch) // Text search filter (client-side, no re-fetch)
document.getElementById("bm-text-filter").addEventListener("input", () => { bmEl("bm-text-filter").addEventListener("input", () => {
bmApplyFilters(); bmApplyFilters();
}); });
document.getElementById("bm-page-prev").addEventListener("click", () => { bmEl("bm-page-prev").addEventListener("click", () => {
bmChangePage(-1); bmChangePage(-1);
}); });
document.getElementById("bm-page-next").addEventListener("click", () => { bmEl("bm-page-next").addEventListener("click", () => {
bmChangePage(1); bmChangePage(1);
}); });
// Form submit // Form submit
document.getElementById("bm-form").addEventListener("submit", bmSave); bmEl("bm-form").addEventListener("submit", (event) => { void bmSave(event); });
// Form cancel // Form cancel
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm); bmEl("bm-form-cancel").addEventListener("click", bmCloseForm);
const formWrap = document.getElementById("bm-form-wrap"); const formWrap = bmEl("bm-form-wrap");
if (formWrap) { if (formWrap) {
formWrap.addEventListener("click", (e) => { formWrap.addEventListener("click", (e) => {
if (e.target === formWrap) bmCloseForm(); if (e.target === formWrap) bmCloseForm();
@@ -719,24 +779,26 @@ function bmPopulateScopePicker() {
} }
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") { if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") {
bmCloseForm(); bmCloseForm();
} }
}); });
// Select-all checkbox // Select-all checkbox
document.getElementById("bm-select-all").addEventListener("change", (e) => { bmEl("bm-select-all").addEventListener("change", (e) => {
const checked = e.target.checked; const checked = (e.currentTarget as HTMLInputElement).checked;
document.querySelectorAll(".bm-row-sel").forEach((cb) => { document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
cb.checked = checked; cb.checked = checked;
if (checked) bmSelected.add(cb.dataset.bmId); const id = cb.dataset.bmId;
else bmSelected.delete(cb.dataset.bmId); if (!id) return;
if (checked) bmSelected.add(id);
else bmSelected.delete(id);
}); });
bmUpdateSelectionUi(); bmUpdateSelectionUi();
}); });
// Select All (across all pages) button // Select All (across all pages) button
document.getElementById("bm-select-all-btn").addEventListener("click", () => { bmEl("bm-select-all-btn").addEventListener("click", () => {
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
if (allSelected) { if (allSelected) {
bmSelected.clear(); bmSelected.clear();
@@ -744,49 +806,53 @@ function bmPopulateScopePicker() {
bmFilteredList.forEach((bm) => bmSelected.add(bm.id)); bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
} }
// Sync visible page checkboxes // Sync visible page checkboxes
document.querySelectorAll(".bm-row-sel").forEach((cb) => { document.querySelectorAll<HTMLInputElement>(".bm-row-sel").forEach((cb) => {
cb.checked = bmSelected.has(cb.dataset.bmId); cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId);
}); });
bmSyncSelectAllCheckbox(); bmSyncSelectAllCheckbox();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
}); });
// Delete Selected button // Delete Selected button
document.getElementById("bm-del-selected-btn").addEventListener("click", () => { bmEl("bm-del-selected-btn").addEventListener("click", () => {
bmDeleteSelected(); void bmDeleteSelected();
}); });
// Move Selected button // Move Selected button
document.getElementById("bm-move-selected-btn").addEventListener("click", () => { bmEl("bm-move-selected-btn").addEventListener("click", () => {
bmMoveSelected(); void bmMoveSelected();
}); });
// Table action buttons and row checkboxes (event delegation) // Table action buttons and row checkboxes (event delegation)
document.getElementById("bm-tbody").addEventListener("click", async (e) => { bmEl("bm-tbody").addEventListener("click", (e) => { void (async () => {
const checkbox = e.target.closest(".bm-row-sel"); if (!(e.target instanceof Element)) return;
const checkbox = e.target.closest<HTMLInputElement>(".bm-row-sel");
if (checkbox) { if (checkbox) {
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId); const id = checkbox.dataset.bmId;
else bmSelected.delete(checkbox.dataset.bmId); if (!id) return;
if (checkbox.checked) bmSelected.add(id);
else bmSelected.delete(id);
bmSyncSelectAllCheckbox(); bmSyncSelectAllCheckbox();
bmUpdateSelectionUi(); bmUpdateSelectionUi();
return; return;
} }
const tuneBtn = e.target.closest(".bm-tune-btn"); const tuneBtn = e.target.closest<HTMLElement>(".bm-tune-btn");
const editBtn = e.target.closest(".bm-edit-btn"); const editBtn = e.target.closest<HTMLElement>(".bm-edit-btn");
const delBtn = e.target.closest(".bm-del-btn"); const delBtn = e.target.closest<HTMLElement>(".bm-del-btn");
if (tuneBtn) { if (tuneBtn) {
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId); const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
if (bm) await bmApply(bm); if (bm) bmApply(bm);
} else if (editBtn) { } else if (editBtn) {
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId); const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
if (bm) bmOpenForm(bm); if (bm) bmOpenForm(bm);
} else if (delBtn) { } else if (delBtn) {
await bmDelete(delBtn.dataset.bmId); const id = delBtn.dataset.bmId;
if (id) await bmDelete(id);
} }
}); })(); });
// Pre-load bookmarks so spectrum markers are visible immediately. // Pre-load bookmarks so spectrum markers are visible immediately.
bmFetch(""); void bmFetch("");
})(); })();
@@ -2,16 +2,56 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore } from "./host.js";
import type { PluginRuntimeWindow } from "./runtime-contract.js";
export {};
type Rgba = [number, number, number, number];
interface CwRenderer {
ready: boolean;
ensureSize(width: number, height: number, dpr: number): boolean;
clear(color: Rgba): void;
fillRect(x: number, y: number, width: number, height: number, color: Rgba): void;
drawSegments(points: number[], color: Rgba, width: number): void;
drawFilledArea(points: number[], baseline: number, color: Rgba): void;
drawPolyline(points: number[], color: Rgba, width: number): void;
drawPoints(points: number[], size: number, color: Rgba): void;
}
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
interface CwToneRange {
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
toneSpanHz: number; lowerSideband: boolean; mode: string;
}
interface CwBridge {
createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options: WebGLContextAttributes) => CwRenderer;
trxParseCssColor?: (color: string) => Rgba;
lastFreqHz?: number;
currentBandwidthHz?: number;
lastSpectrumData?: CwSpectrum;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
applyCwAutoUi?: (enabled: boolean) => void;
applyCwAutoUiFromServer?: (enabled: boolean) => void;
updateCwBar?: () => void;
clearCwBar?: () => void;
closeCwBar?: () => void;
refreshCwTonePicker?: () => void;
}
const cwWindow = window as unknown as CwBridge & PluginRuntimeWindow;
// --- CW (Morse) Decoder Plugin (server-side decode) --- // --- CW (Morse) Decoder Plugin (server-side decode) ---
const cwStatusEl = document.getElementById("cw-status"); const cwStatusEl = document.getElementById("cw-status");
const cwOutputEl = document.getElementById("cw-output"); const cwOutputEl = document.getElementById("cw-output");
const cwAutoInput = document.getElementById("cw-auto"); const cwAutoInput = document.getElementById("cw-auto") as HTMLInputElement | null;
const cwWpmInput = document.getElementById("cw-wpm"); const cwWpmInput = document.getElementById("cw-wpm") as HTMLInputElement | null;
const cwToneInput = document.getElementById("cw-tone"); const cwToneInput = document.getElementById("cw-tone") as HTMLInputElement | null;
const cwSignalIndicator = document.getElementById("cw-signal-indicator"); const cwSignalIndicator = document.getElementById("cw-signal-indicator");
const cwToneCanvas = document.getElementById("cw-tone-waterfall"); const cwToneCanvas = document.getElementById("cw-tone-waterfall") as HTMLCanvasElement | null;
const cwToneGl = typeof createTrxWebGlRenderer === "function" const cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
: null; : null;
const cwTonePickerEl = document.querySelector(".cw-tone-picker"); const cwTonePickerEl = document.querySelector(".cw-tone-picker");
const cwToneRangeEl = document.getElementById("cw-tone-range"); const cwToneRangeEl = document.getElementById("cw-tone-range");
@@ -24,17 +64,21 @@ const CW_WPM_MAX = 40;
const CW_BAR_WINDOW_MS = 15 * 60 * 1000; const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
const CW_BAR_LINE_GAP_MS = 5000; const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0; let cwLastAppendTime = 0;
let cwTonePickerRaf = null; let cwTonePickerRaf: number | null = null;
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}] let cwBarHistory: CwLine[] = [];
let cwBarCurrentLine = null; // accumulates chars until gap/newline let cwBarCurrentLine: CwLine | null = null;
let cwBarDismissedAtMs = 0; let cwBarDismissedAtMs = 0;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet // Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the // acknowledged). While set, server-state updates must not override the
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value // checkbox so that a concurrent SSE event carrying the *old* cw_auto value
// does not immediately undo the user's choice. // does not immediately undo the user's choice.
let cwAutoLocalOverride = null; let cwAutoLocalOverride: boolean | null = null;
function applyCwAutoUi(enabled) { function escapeCwHtml(input: string): string {
return hostCore.escapeMapHtml(input);
}
function applyCwAutoUi(enabled: boolean): void {
if (cwAutoInput) cwAutoInput.checked = enabled; if (cwAutoInput) cwAutoInput.checked = enabled;
if (cwWpmInput) { if (cwWpmInput) {
cwWpmInput.disabled = enabled; cwWpmInput.disabled = enabled;
@@ -48,16 +92,16 @@ function applyCwAutoUi(enabled) {
cwTonePickerEl.classList.toggle("is-auto", enabled); cwTonePickerEl.classList.toggle("is-auto", enabled);
} }
} }
window.applyCwAutoUi = applyCwAutoUi; cwWindow.applyCwAutoUi = applyCwAutoUi;
// Called by app.js render() when a server-state snapshot arrives. Ignores // Called by app.js render() when a server-state snapshot arrives. Ignores
// the update while cwAutoLocalOverride is set (user change still in-flight). // the update while cwAutoLocalOverride is set (user change still in-flight).
window.applyCwAutoUiFromServer = function(enabled) { cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
if (cwAutoLocalOverride !== null) return; if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled); applyCwAutoUi(enabled);
}; };
function cwBarFlushCurrentLine() { function cwBarFlushCurrentLine(): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine); cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50; if (cwBarHistory.length > 50) cwBarHistory.length = 50;
@@ -65,15 +109,15 @@ function cwBarFlushCurrentLine() {
cwBarCurrentLine = null; cwBarCurrentLine = null;
} }
function updateCwBar() { function updateCwBar(): void {
if (!cwBarOverlay) return; if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase(); const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR"; const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
// Prepend the in-progress line so characters appear immediately // Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0); const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none"; cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = ""; cwBarOverlay.innerHTML = "";
@@ -94,22 +138,22 @@ function updateCwBar() {
for (const line of liveLines.slice(0, 8)) { for (const line of liveLines.slice(0, 8)) {
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : ""; const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
const meta = [ const meta = [
line.wpm ? `${line.wpm} WPM` : null, line.wpm ? `${String(line.wpm)} WPM` : null,
line.tone_hz ? `${line.tone_hz} Hz` : null, line.tone_hz ? `${String(line.tone_hz)} Hz` : null,
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame">` + html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` + `<div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` +
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") +
`</div></div>`; `</div></div>`;
} }
cwBarOverlay.innerHTML = html; cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex"; cwBarOverlay.style.display = "flex";
} }
window.updateCwBar = updateCwBar; cwWindow.updateCwBar = updateCwBar;
window.clearCwBar = function() { cwWindow.clearCwBar = function() {
window.resetCwHistoryView(); resetCwHistoryView();
}; };
window.closeCwBar = function() { cwWindow.closeCwBar = function() {
cwBarDismissedAtMs = Date.now(); cwBarDismissedAtMs = Date.now();
if (cwBarOverlay) { if (cwBarOverlay) {
cwBarOverlay.style.display = "none"; cwBarOverlay.style.display = "none";
@@ -117,34 +161,31 @@ window.closeCwBar = function() {
} }
}; };
function clampCwWpm(wpm) { function clampCwWpm(wpm: unknown): number {
const numeric = Number(wpm); const numeric = Number(wpm);
if (!Number.isFinite(numeric)) return 15; if (!Number.isFinite(numeric)) return 15;
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric))); return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
} }
function clampCwTone(tone) { function clampCwTone(tone: unknown): number {
const numeric = Number(tone); const numeric = Number(tone);
if (!Number.isFinite(numeric)) return 700; if (!Number.isFinite(numeric)) return 700;
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric))); return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
} }
function currentCwToneRange() { function currentCwToneRange(): CwToneRange | null {
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN; const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN; const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null; return null;
} }
const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const lowerSideband = mode === "CWR"; const lowerSideband = mode === "CWR";
const upperSideband = mode === "CW"; const upperSideband = mode === "CW";
if (!lowerSideband && !upperSideband) return null; if (!lowerSideband && !upperSideband) return null;
const toneMinHz = CW_TONE_MIN_HZ; const toneMinHz = CW_TONE_MIN_HZ;
const toneMaxHz = CW_TONE_MAX_HZ; const toneMaxHz = CW_TONE_MAX_HZ;
if (toneMaxHz < toneMinHz) {
return null;
}
return { return {
tunedHz, tunedHz,
bandwidthHz, bandwidthHz,
@@ -156,20 +197,20 @@ function currentCwToneRange() {
}; };
} }
function cwToneToRfHz(range, toneHz) { function cwToneToRfHz(range: CwToneRange | null, toneHz: number): number {
if (!range) return NaN; if (!range) return NaN;
return range.lowerSideband return range.lowerSideband
? range.tunedHz - toneHz ? range.tunedHz - toneHz
: range.tunedHz + toneHz; : range.tunedHz + toneHz;
} }
function toneClampForRange(tone, range) { function toneClampForRange(tone: unknown, range: CwToneRange | null): number {
const clamped = clampCwTone(tone); const clamped = clampCwTone(tone);
if (!range) return clamped; if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped)); return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
} }
function ensureCwToneCanvasResolution() { function ensureCwToneCanvasResolution(): boolean {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false; if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect(); const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width); const cssWidth = Math.round(rect.width);
@@ -181,7 +222,7 @@ function ensureCwToneCanvasResolution() {
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
} }
function drawCwTonePicker() { function drawCwTonePicker(): void {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
@@ -190,12 +231,12 @@ function drawCwTonePicker() {
cwToneGl.clear([0, 0, 0, 0]); cwToneGl.clear([0, 0, 0, 0]);
const range = currentCwToneRange(); const range = currentCwToneRange();
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) { if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
if (cwToneRangeEl) { if (cwToneRangeEl) {
const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") { if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required"; cwToneRangeEl.textContent = "CW/CWR mode required";
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) { } else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
cwToneRangeEl.textContent = "Waiting for spectrum"; cwToneRangeEl.textContent = "Waiting for spectrum";
} }
} }
@@ -205,15 +246,15 @@ function drawCwTonePicker() {
if (cwToneRangeEl) { if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side"; const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`; cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
} }
const bins = window.lastSpectrumData.bins; const bins = cwWindow.lastSpectrumData.bins;
const sampleRate = Number(window.lastSpectrumData.sample_rate); const sampleRate = cwWindow.lastSpectrumData.sample_rate;
const centerHz = Number(window.lastSpectrumData.center_hz); const centerHz = cwWindow.lastSpectrumData.center_hz;
const maxIdx = Math.max(1, bins.length - 1); const maxIdx = Math.max(1, bins.length - 1);
const fullLoHz = centerHz - sampleRate / 2; const fullLoHz = centerHz - sampleRate / 2;
const tones = new Array(width).fill(-140); const tones: number[] = new Array<number>(width).fill(-140);
for (let x = 0; x < width; x += 1) { for (let x = 0; x < width; x += 1) {
const frac = width <= 1 ? 0 : x / (width - 1); const frac = width <= 1 ? 0 : x / (width - 1);
const toneHz = range.toneMinHz + frac * range.toneSpanHz; const toneHz = range.toneMinHz + frac * range.toneSpanHz;
@@ -223,17 +264,17 @@ function drawCwTonePicker() {
tones[x] = power; tones[x] = power;
} }
const smoothed = new Array(width).fill(-140); const smoothed: number[] = new Array<number>(width).fill(-140);
const smoothRadius = Math.max(1, Math.round(width / 180)); const smoothRadius = Math.max(1, Math.round(width / 180));
for (let x = 0; x < width; x += 1) { for (let x = 0; x < width; x += 1) {
let sum = 0; let sum = 0;
let count = 0; let count = 0;
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) { for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
if (i < 0 || i >= width) continue; if (i < 0 || i >= width) continue;
sum += tones[i]; sum += tones[i] ?? -140;
count += 1; count += 1;
} }
smoothed[x] = count > 0 ? sum / count : tones[x]; smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
} }
const sorted = smoothed.slice().sort((a, b) => a - b); const sorted = smoothed.slice().sort((a, b) => a - b);
@@ -242,23 +283,23 @@ function drawCwTonePicker() {
const floorDb = Math.min(q20 - 2, q95 - 10); const floorDb = Math.min(q20 - 2, q95 - 10);
const ceilDb = Math.max(floorDb + 18, q95 + 2); const ceilDb = Math.max(floorDb + 18, q95 + 2);
const dbSpan = Math.max(1, ceilDb - floorDb); const dbSpan = Math.max(1, ceilDb - floorDb);
const yForDb = (db) => { const yForDb = (db: number): number => {
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan)); const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
return Math.round((1 - n) * (height - 1)); return Math.round((1 - n) * (height - 1));
}; };
const rootStyle = getComputedStyle(document.documentElement); const rootStyle = getComputedStyle(document.documentElement);
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f"; const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
const parseColor = typeof window.trxParseCssColor === "function" const parseColor = typeof cwWindow.trxParseCssColor === "function"
? window.trxParseCssColor ? cwWindow.trxParseCssColor
: null; : null;
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; const accentRgba: Rgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; const axisColor: Rgba = [230 / 255, 235 / 255, 245 / 255, 0.15];
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]); cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
const hGridCount = 4; const hGridCount = 4;
const gridSegments = []; const gridSegments: number[] = [];
for (let i = 1; i <= hGridCount; i += 1) { for (let i = 1; i <= hGridCount; i += 1) {
const y = Math.round((i / (hGridCount + 1)) * (height - 1)); const y = Math.round((i / (hGridCount + 1)) * (height - 1));
gridSegments.push(0, y, width, y); gridSegments.push(0, y, width, y);
@@ -267,7 +308,7 @@ function drawCwTonePicker() {
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200; const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
const tickSegments = []; const tickSegments: number[] = [];
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) { for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
const frac = (tone - range.toneMinHz) / range.toneSpanHz; const frac = (tone - range.toneMinHz) / range.toneSpanHz;
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))); const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
@@ -275,9 +316,9 @@ function drawCwTonePicker() {
} }
cwToneGl.drawSegments(tickSegments, axisColor, 1); cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = []; const linePoints: number[] = [];
for (let x = 0; x < width; x += 1) { for (let x = 0; x < width; x += 1) {
linePoints.push(x, yForDb(smoothed[x])); linePoints.push(x, yForDb(smoothed[x] ?? -140));
} }
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]); cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2)); cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
@@ -285,7 +326,7 @@ function drawCwTonePicker() {
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range); const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz; const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]); const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5); cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]); cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
@@ -294,14 +335,14 @@ function drawCwTonePicker() {
} }
} }
async function setCwTone(tone, { syncInput = true } = {}) { async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: boolean } = {}): Promise<void> {
const range = currentCwToneRange(); const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range); const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) { if (cwToneInput && syncInput) {
cwToneInput.value = clamped; cwToneInput.value = String(clamped);
} }
try { try {
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); await hostCore.postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) { } catch (e) {
console.error("CW tone set failed", e); console.error("CW tone set failed", e);
} }
@@ -309,40 +350,43 @@ async function setCwTone(tone, { syncInput = true } = {}) {
} }
if (cwAutoInput) { if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => { cwAutoInput.addEventListener("change", () => {
const enabled = cwAutoInput.checked; void (async () => {
cwAutoLocalOverride = enabled; const enabled = cwAutoInput.checked;
applyCwAutoUi(enabled); cwAutoLocalOverride = enabled;
try { applyCwAutoUi(enabled);
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); try {
drawCwTonePicker(); await hostCore.postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
} catch (e) { drawCwTonePicker();
console.error("CW auto toggle failed", e); } catch (error: unknown) {
} finally { console.error("CW auto toggle failed", error);
cwAutoLocalOverride = null; } finally {
} cwAutoLocalOverride = null;
}
})();
}); });
} }
if (cwWpmInput) { if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => { cwWpmInput.addEventListener("change", () => {
if (cwAutoInput && cwAutoInput.checked) return; void (async () => {
const wpm = clampCwWpm(cwWpmInput.value); if (cwAutoInput?.checked) return;
cwWpmInput.value = wpm; const wpm = clampCwWpm(cwWpmInput.value);
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } cwWpmInput.value = String(wpm);
catch (e) { console.error("CW WPM set failed", e); } try { await hostCore.postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (error: unknown) { console.error("CW WPM set failed", error); }
})();
}); });
} }
if (cwToneInput) { if (cwToneInput) {
cwToneInput.addEventListener("change", async () => { cwToneInput.addEventListener("change", () => {
if (cwAutoInput?.checked) return; if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
await setCwTone(cwToneInput.value);
}); });
} }
if (cwToneCanvas) { if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (event) => { cwToneCanvas.addEventListener("click", (event) => {
if (cwAutoInput?.checked) return; if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect(); const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return; if (rect.width <= 0) return;
@@ -350,31 +394,33 @@ if (cwToneCanvas) {
if (!range) return; if (!range) return;
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const tone = range.toneMinHz + frac * range.toneSpanHz; const tone = range.toneMinHz + frac * range.toneSpanHz;
await setCwTone(tone); void setCwTone(tone);
}); });
} }
window.resetCwHistoryView = function() { function resetCwHistoryView(): void {
if (cwOutputEl) cwOutputEl.innerHTML = ""; if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0; cwLastAppendTime = 0;
cwBarHistory = []; cwBarHistory = [];
cwBarCurrentLine = null; cwBarCurrentLine = null;
updateCwBar(); updateCwBar();
drawCwTonePicker(); drawCwTonePicker();
}; }
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
if (!confirm("Clear all CW decode history? This cannot be undone.")) return; void (async () => {
try { if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
await postPath("/clear_cw_decode"); try {
window.resetCwHistoryView(); await hostCore.postPath("/clear_cw_decode");
} catch (e) { resetCwHistoryView();
console.error("CW history clear failed", e); } catch (error: unknown) {
} console.error("CW history clear failed", error);
}
})();
}); });
// --- Server-side CW decode handler --- // --- Server-side CW decode handler ---
window.onServerCw = function(evt) { function onServerCw(evt: CwEvent): void {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) { if (evt.text && cwOutputEl) {
// Append decoded text to output // Append decoded text to output
@@ -390,7 +436,9 @@ window.onServerCw = function(evt) {
lastLine.textContent += evt.text; lastLine.textContent += evt.text;
} }
while (cwOutputEl.children.length > CW_MAX_LINES) { while (cwOutputEl.children.length > CW_MAX_LINES) {
cwOutputEl.removeChild(cwOutputEl.firstChild); const firstChild = cwOutputEl.firstChild;
if (!firstChild) break;
cwOutputEl.removeChild(firstChild);
} }
cwOutputEl.scrollTop = cwOutputEl.scrollHeight; cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
} }
@@ -417,10 +465,10 @@ window.onServerCw = function(evt) {
} }
if (!cwAutoInput || cwAutoInput.checked) { if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = clampCwWpm(evt.wpm); cwWpmInput.value = String(clampCwWpm(evt.wpm));
} }
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) { if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange()); cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
} }
} }
if (cwTonePickerRaf != null) return; if (cwTonePickerRaf != null) return;
@@ -428,17 +476,24 @@ window.onServerCw = function(evt) {
cwTonePickerRaf = null; cwTonePickerRaf = null;
drawCwTonePicker(); drawCwTonePicker();
}); });
}; }
window.restoreCwHistory = function(events) { function restoreCwHistory(events: CwEvent[]): void {
if (!Array.isArray(events) || events.length === 0) return; if (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) { for (const evt of events) {
window.onServerCw(evt); onServerCw(evt);
} }
}; }
window.refreshCwTonePicker = function refreshCwTonePicker() { cwWindow.trxPluginRuntime.registerDecoder({
id: "cw",
onMessage: onServerCw,
restore: restoreCwHistory,
reset: resetCwHistoryView,
});
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
drawCwTonePicker(); drawCwTonePicker();
}; };
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { initializeFtxDecoder } from "./ftx-family";
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3_750 });
@@ -0,0 +1,7 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { initializeFtxDecoder } from "./ftx-family";
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7_500 });
@@ -0,0 +1,13 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import {
initializeFt8FamilyBar,
initializeFtxDecoder,
installFtxCompatibilityHelpers,
} from "./ftx-family";
installFtxCompatibilityHelpers();
initializeFt8FamilyBar();
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15_000, periodDigits: 0 });
@@ -0,0 +1,328 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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";
export interface FtxMessage {
message?: string | undefined;
ts_ms?: number | undefined;
_tsMs?: number | undefined;
snr_db?: number | undefined;
dt_s?: number | undefined;
freq_hz?: number | undefined;
receiver?: unknown;
[key: string]: unknown;
}
export interface LocatorDetails { grid: string; station: string | null; source: string | null; target: string | null }
interface BarFrames { count: number; newestTsMs: number; html: string }
interface ConfirmApi {
confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean>;
}
interface FtxBridge {
ft8BaseHz?: number;
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
renderFt8Message?: (message: string) => string;
ft8EscapeHtml?: (message: string) => string;
ft8ExtractLocatorDetails?: (message: string) => LocatorDetails[];
ft8ExtractAllGrids?: (message: string) => string[];
ft8ExtractLikelyCallsign?: (message: string) => string | null;
getDecodeRigMeta?: () => unknown;
mapAddLocator?: (
raw: string,
grids: string[],
decoder: FtxDecoderId,
station: string | null,
message: FtxMessage,
) => void;
navigateToMapLocator?: (grid: string, decoder: FtxDecoderId) => void;
clearMapMarkersByType?: (decoder: FtxDecoderId) => void;
setFt8FamilyBarDecoder?: (decoder: FtxDecoderId) => void;
updateFt8Bar?: () => void;
registerFt8FamilyBarRenderer?: (decoder: FtxDecoderId, renderer: () => BarFrames) => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<void>;
trxUi: ConfirmApi;
clearFt8Bar?: () => void;
closeFt8Bar?: () => void;
[key: string]: unknown;
}
interface FtxConfig {
id: FtxDecoderId;
label: "FT2" | "FT4" | "FT8";
periodMs: number;
periodDigits?: number;
}
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;
}
function isAlphaNumeric(value: string | undefined): boolean {
return value !== undefined && /[A-Za-z0-9]/.test(value);
}
function isGrid(value: string): boolean {
const normalized = value.trim().toUpperCase();
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized)
&& normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
}
export function escapeFtxHtml(input: string): string {
return input.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
export function extractFtxGrids(message: string): string[] {
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
}
function tokenize(message: string): string[] {
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
}
function isCallsign(token: string | undefined): token is string {
return token !== undefined && token.length >= 3 && token.length <= 12
&& !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token)
&& /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
}
export function extractFtxLocatorDetails(message: string): LocatorDetails[] {
const tokens = tokenize(message);
const grids = extractFtxGrids(message);
const gridIndex = tokens.findIndex(isGrid);
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
const target = directed ? callsigns[0] ?? null : null;
return grids.map((grid) => ({ grid, station: source, source, target }));
}
export function extractFtxCallsign(message: string): string | null {
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
}
export function renderFtxMessage(message: string): string {
let html = "";
let index = 0;
while (index < message.length) {
if (!isAlphaNumeric(message[index])) {
html += escapeFtxHtml(message[index] ?? "");
index += 1;
continue;
}
let end = index + 1;
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
const token = message.slice(index, end);
const grid = token.toUpperCase();
html += isGrid(grid)
? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`
: escapeFtxHtml(token);
index = end;
}
return html;
}
export function installFtxCompatibilityHelpers(): void {
bridge.renderFt8Message = renderFtxMessage;
bridge.ft8EscapeHtml = escapeFtxHtml;
bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
bridge.ft8ExtractAllGrids = extractFtxGrids;
bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
}
export function initializeFt8FamilyBar(): void {
const labels: Record<FtxDecoderId, string> = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
const builders: Partial<Record<FtxDecoderId, () => BarFrames>> = {};
const dismissed: Record<FtxDecoderId, number> = { ft8: 0, ft4: 0, ft2: 0 };
const overlay = document.getElementById("ft8-bar-overlay");
let active: FtxDecoderId = "ft8";
const update = (): void => {
if (!overlay) return;
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value ?? "").toUpperCase();
const result = builders[active]?.();
if ((mode !== "DIG" && mode !== "USB") || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) {
overlay.style.display = "none";
overlay.innerHTML = "";
return;
}
const label = labels[active];
overlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">&times;</button></span></div>${result.html}`;
overlay.style.display = "flex";
};
bridge.registerFt8FamilyBarRenderer = (decoder, builder) => { builders[decoder] = builder; };
bridge.setFt8FamilyBarDecoder = (decoder) => { active = decoder; update(); };
bridge.updateFt8Bar = update;
bridge.clearFt8Bar = () => {
bridge.trxPluginRuntime.reset(active);
};
bridge.closeFt8Bar = () => {
dismissed[active] = Date.now();
update();
};
}
export function initializeFtxDecoder(config: FtxConfig): void {
const { id, label, periodMs, periodDigits = 1 } = config;
const status = document.getElementById(`${id}-status`);
const period = document.getElementById(`${id}-period`);
const messagesElement = document.getElementById(`${id}-messages`);
const filterInput = document.getElementById(`${id}-filter`) as HTMLInputElement | null;
let filterText = "";
let history: FtxMessage[] = [];
const renderMessage = (message: string): string => {
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
};
const retentionMs = (): number => bridge.getDecodeHistoryRetentionMs?.() ?? 86_400_000;
const prune = (): void => {
const cutoff = Date.now() - retentionMs();
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
};
const schedule = (job: () => void): void => {
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
else job();
};
const displayFrequency = (value: unknown): number | null => {
const raw = finiteNumber(value);
if (raw === null) return null;
const base = finiteNumber(bridge.ft8BaseHz);
return base !== null && base > 0 && raw >= 0 && raw < 100_000 ? base + raw : raw;
};
const renderRow = (message: FtxMessage): HTMLDivElement => {
const row = document.createElement("div");
row.className = "ft8-row";
const raw = message.message ?? "";
row.dataset.message = raw.toUpperCase();
row.dataset.decoder = id;
const storedFrequency = finiteNumber(message.freq_hz);
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
const snr = finiteNumber(message.snr_db);
const delta = finiteNumber(message.dt_s);
const frequency = displayFrequency(message.freq_hz);
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
return row;
};
const render = (): void => {
prune();
if (!messagesElement) return;
const fragment = document.createDocumentFragment();
let count = 0;
for (const message of history) {
if (count >= 200) break;
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
fragment.appendChild(renderRow(message));
count += 1;
}
messagesElement.replaceChildren(fragment);
};
const normalize = (message: FtxMessage): FtxMessage => {
const raw = message.message ?? "";
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
const grids = locatorDetails.length > 0
? locatorDetails.map(({ grid }) => grid)
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
const frequency = displayFrequency(message.freq_hz);
if (grids.length > 0) {
bridge.mapAddLocator?.(raw, grids, id, station, {
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
});
}
return {
receiver: bridge.getDecodeRigMeta?.() ?? null,
ts_ms: message.ts_ms,
snr_db: message.snr_db,
dt_s: message.dt_s,
freq_hz: frequency ?? message.freq_hz,
message: message.message,
_tsMs: finiteNumber(message.ts_ms) ?? Date.now(),
};
};
const receiveBatch = (messages: FtxMessage[]): void => {
if (messages.length === 0) return;
if (status) status.textContent = "Receiving";
history = messages.map(normalize).reverse().concat(history);
prune();
bridge.setFt8FamilyBarDecoder?.(id);
bridge.updateFt8Bar?.();
schedule(render);
};
const reset = (): void => {
history = [];
bridge.updateFt8Bar?.();
render();
bridge.clearMapMarkersByType?.(id);
};
const barFrames = (): BarFrames => {
const recent = history
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
.slice(0, 8);
let html = "";
for (const message of recent) {
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
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);
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part): part is string => part !== null).join(" · ");
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
}
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
};
bridge.trxPluginRuntime.registerDecoder({
id,
onMessage: (message: FtxMessage) => { receiveBatch([message]); },
onBatch: receiveBatch,
restore: receiveBatch,
prune: () => { prune(); render(); },
reset,
});
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
const updatePeriod = (): void => {
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1000).toFixed(periodDigits)}s`;
};
updatePeriod();
window.setInterval(updatePeriod, 250);
filterInput?.addEventListener("input", () => { filterText = filterInput.value.trim().toUpperCase(); render(); });
messagesElement?.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const grid = event.target.closest<HTMLElement>(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
if (grid) { bridge.navigateToMapLocator?.(grid.toUpperCase(), id); event.preventDefault(); }
});
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
toggle?.addEventListener("click", () => { void (async () => {
try {
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
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 hostCore.postPath(`/clear_${id}_decode`); reset(); }
catch (error: unknown) { console.error(`${label} history clear failed`, error); }
})(); });
}
@@ -0,0 +1,281 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import { hostCore, hostState } from "./host.js";
import {
aprsAgeText,
aprsPacketCategory,
collapseAprsDuplicates,
normalizeAprsPacket,
renderAprsPacketRow,
type AprsPacket,
type AprsTypeFilter,
} from "./aprs-shared";
import type { PluginRuntimeWindow } from "./runtime-contract";
interface HfAprsBridge {
getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
navigateToAprsMap?: (lat: number, lon: number) => void;
getDecodeRigMeta?: () => unknown;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
}
const hfAprsWindow = window as unknown as HfAprsBridge;
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
const hfAprsStatus = document.getElementById("hf-aprs-status");
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
const hfAprsFilterInput = document.getElementById("hf-aprs-filter") as HTMLInputElement | null;
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
let hfAprsFilterText = "";
let hfAprsPacketHistory: AprsPacket[] = [];
let hfAprsOnlyPos = false;
let hfAprsHideCrc = false;
let hfAprsCollapseDup = false;
let hfAprsTypeFilter: AprsTypeFilter = "all";
function currentHfAprsHistoryRetentionMs(): number {
return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function"
? hfAprsWindow.getDecodeHistoryRetentionMs()
: 24 * 60 * 60 * 1000;
}
function pruneHfAprsPacketHistory() {
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
}
function scheduleHfAprsHistoryRender() {
if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") {
hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => { renderHfAprsHistory(); });
return;
}
renderHfAprsHistory();
}
function hfAprsDistanceText(pkt: AprsPacket): string {
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`;
}
function hfAprsFilterMatch(pkt: AprsPacket): boolean {
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
if (hfAprsHideCrc && !pkt.crcOk) return false;
if (hfAprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
if (!hfAprsFilterText) return true;
const haystack = [
pkt.srcCall,
pkt.destCall,
pkt.path,
pkt.info,
pkt.type,
pkt.lat != null ? pkt.lat.toFixed(4) : "",
pkt.lon != null ? pkt.lon.toFixed(4) : "",
aprsPacketCategory(pkt),
]
.filter(Boolean)
.join(" ")
.toUpperCase();
return haystack.includes(hfAprsFilterText);
}
function hfAprsVisiblePackets(): AprsPacket[] {
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
return packets.filter(hfAprsFilterMatch);
}
const collapseHfAprsDuplicates = collapseAprsDuplicates;
function updateHfAprsSummary() {
const visible = hfAprsVisiblePackets();
if (hfAprsTotalCountEl) {
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
}
if (hfAprsVisibleCountEl) {
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
}
if (hfAprsLatestSeenEl) {
const latest = hfAprsPacketHistory[0];
if (!latest) {
hfAprsLatestSeenEl.textContent = "No packets yet";
} else {
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
}
}
}
function updateHfAprsChipState() {
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
});
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
}
// HF traffic goes in the same row as VHF, marked with the band it came in on.
// This was a second copy of the same forty lines of markup.
function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement {
return renderAprsPacketRow(pkt, {
fresh: isFresh,
badge: "HF",
distance: hfAprsDistanceText(pkt),
onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); },
onCopy: (text: string) => { void copyHfAprsCoords(text); },
});
}
async function copyHfAprsCoords(text: string): Promise<void> {
try {
const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined;
if (!clipboard) return;
await clipboard.writeText(text);
hostCore.showHint("Coordinates copied", 1200);
} catch {
hostCore.showHint("Copy failed", 1500);
}
}
function renderHfAprsHistory() {
pruneHfAprsPacketHistory();
if (!hfAprsPacketsEl) {
updateHfAprsSummary();
updateHfAprsChipState();
return;
}
const visible = hfAprsVisiblePackets();
const fragment = document.createDocumentFragment();
for (const [index, packet] of visible.entries()) {
fragment.appendChild(renderHfAprsRow(packet, index === 0));
}
hfAprsPacketsEl.replaceChildren(fragment);
updateHfAprsSummary();
updateHfAprsChipState();
}
function resetHfAprsHistoryView(): void {
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
hfAprsPacketHistory = [];
renderHfAprsHistory();
}
function pruneHfAprsHistoryView(): void {
pruneHfAprsPacketHistory();
renderHfAprsHistory();
}
function addHfAprsPacket(pkt: AprsPacket): void {
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
pkt._tsMs = tsMs;
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hfAprsPacketHistory.unshift(pkt);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
}
function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
return normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
}
function onServerHfAprsBatch(packets: AprsPacket[]): void {
if (!Array.isArray(packets) || packets.length === 0) return;
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
const normalized: AprsPacket[] = [];
for (const pkt of packets) {
const next = normalizeServerHfAprsPacket(pkt);
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
next._tsMs = tsMs;
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
normalized.push(next);
}
normalized.reverse();
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
pruneHfAprsPacketHistory();
scheduleHfAprsHistoryRender();
}
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
hfAprsDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
try {
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
await hostCore.postPath("/toggle_hf_aprs_decode");
} catch (e) {
console.error("HF APRS toggle failed", e);
}
})(); });
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 hostCore.postPath("/clear_hf_aprs_decode");
resetHfAprsHistoryView();
} catch (e) {
console.error("HF APRS history clear failed", e);
}
})(); });
if (hfAprsOnlyPosBtn) {
hfAprsOnlyPosBtn.addEventListener("click", () => {
hfAprsOnlyPos = !hfAprsOnlyPos;
renderHfAprsHistory();
});
}
if (hfAprsHideCrcBtn) {
hfAprsHideCrcBtn.addEventListener("click", () => {
hfAprsHideCrc = !hfAprsHideCrc;
renderHfAprsHistory();
});
}
if (hfAprsCollapseDupBtn) {
hfAprsCollapseDupBtn.addEventListener("click", () => {
hfAprsCollapseDup = !hfAprsCollapseDup;
renderHfAprsHistory();
});
}
(["all", "position", "message", "weather", "telemetry", "other"] as const).forEach((type) => {
const btn = document.getElementById(`hf-aprs-type-${type}`);
if (!btn) return;
btn.addEventListener("click", () => {
hfAprsTypeFilter = type;
renderHfAprsHistory();
});
});
if (hfAprsFilterInput) {
hfAprsFilterInput.addEventListener("input", () => {
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
renderHfAprsHistory();
});
}
// --- Server-side HF APRS decode handler ---
function onServerHfAprs(pkt: AprsPacket): void {
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
}
renderHfAprsHistory();
(window as unknown as PluginRuntimeWindow).trxPluginRuntime.registerDecoder<AprsPacket>({
id: "hf_aprs",
onMessage: onServerHfAprs,
onBatch: onServerHfAprsBatch,
restore: onServerHfAprsBatch,
reset: resetHfAprsHistoryView,
prune: pruneHfAprsHistoryView,
});
@@ -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;
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export interface DecoderPlugin<TMessage = unknown> {
readonly id: string;
onMessage?(message: TMessage): void;
onBatch?(messages: TMessage[]): void;
restore?(messages: TMessage[]): void;
reset?(): void;
prune?(): void;
/** Replay everything the plugin is holding onto the map. Called when the map
* module attaches, which can happen long after the decodes arrived. */
syncMap?(): void;
}
export interface TrxPluginRuntime {
registerDecoder<TMessage>(plugin: DecoderPlugin<TMessage>): () => void;
dispatch(id: string, message: unknown): boolean;
dispatchBatch(id: string, messages: unknown[]): boolean;
restore(id: string, messages: unknown[]): boolean;
reset(id: string): boolean;
resetAll(): void;
prune(id: string): boolean;
syncMapAll(): void;
clearQueued(): void;
hasDecoder(id: string): boolean;
}
export interface PluginRuntimeWindow { trxPluginRuntime: TrxPluginRuntime }
@@ -2,6 +2,22 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import type {
SatelliteBookmark,
SatelliteScheduleConfig,
SatelliteScheduleEntry,
SatelliteSchedulerApi,
SatelliteSchedulerStatus,
} from "./satellite-types";
import type { SchedulerConfig, SchedulerService } from "./scheduler-types.js";
interface SatelliteSchedulerWindow {
trx?: { modules?: { scheduler?: SchedulerService } };
satScheduler?: SatelliteSchedulerApi;
trxUi?: { notify(message: string, options: { kind: "error" }): void };
}
const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow;
// Satellite Pass Scheduling UI // Satellite Pass Scheduling UI
// Manages the satellite overlay section within the background decoding scheduler. // Manages the satellite overlay section within the background decoding scheduler.
// Communicates with scheduler.js via a thin window API for shared state access. // Communicates with scheduler.js via a thin window API for shared state access.
@@ -11,93 +27,91 @@
// ── DOM references (cached once) ────────────────────────────────── // ── DOM references (cached once) ──────────────────────────────────
const dom = { const dom = {
enabled: document.getElementById("scheduler-sat-enabled"), enabled: document.getElementById("scheduler-sat-enabled") as HTMLInputElement | null,
pretune: document.getElementById("scheduler-sat-pretune"), pretune: document.getElementById("scheduler-sat-pretune") as HTMLInputElement | null,
body: document.getElementById("scheduler-sat-body"), body: document.getElementById("scheduler-sat-body"),
tbody: document.getElementById("scheduler-sat-tbody"), tbody: document.getElementById("scheduler-sat-tbody"),
addBtn: document.getElementById("scheduler-sat-add-btn"), addBtn: document.getElementById("scheduler-sat-add-btn"),
passStatus: document.getElementById("scheduler-sat-pass-status"), passStatus: document.getElementById("scheduler-sat-pass-status"),
formWrap: document.getElementById("sch-sat-form-wrap"), formWrap: document.getElementById("sch-sat-form-wrap"),
formTitle: document.getElementById("sch-sat-form-title"), formTitle: document.getElementById("sch-sat-form-title"),
form: document.getElementById("sch-sat-form"), form: document.getElementById("sch-sat-form") as HTMLFormElement | null,
formCancel: document.getElementById("sch-sat-form-cancel"), formCancel: document.getElementById("sch-sat-form-cancel"),
preset: document.getElementById("scheduler-sat-preset"), preset: document.getElementById("scheduler-sat-preset") as HTMLSelectElement | null,
name: document.getElementById("scheduler-sat-name"), name: document.getElementById("scheduler-sat-name") as HTMLInputElement | null,
norad: document.getElementById("scheduler-sat-norad"), norad: document.getElementById("scheduler-sat-norad") as HTMLInputElement | null,
bookmark: document.getElementById("scheduler-sat-bookmark"), bookmark: document.getElementById("scheduler-sat-bookmark") as HTMLSelectElement | null,
minEl: document.getElementById("scheduler-sat-min-el"), minEl: document.getElementById("scheduler-sat-min-el") as HTMLInputElement | null,
priority: document.getElementById("scheduler-sat-priority"), priority: document.getElementById("scheduler-sat-priority") as HTMLInputElement | null,
centerHz: document.getElementById("scheduler-sat-center-hz"), centerHz: document.getElementById("scheduler-sat-center-hz") as HTMLInputElement | null,
}; };
// ── Local state ─────────────────────────────────────────────────── // ── Local state ───────────────────────────────────────────────────
let editIdx = null; // null = adding, number = editing let editIdx: number | null = null;
let eventsWired = false;
// ── Scheduler bridge ────────────────────────────────────────────── // ── Scheduler bridge ──────────────────────────────────────────────
// These accessors call into scheduler.js via window.schedulerBridge, // These accessors use the shared typed scheduler service when available.
// which is set up by scheduler.js after it initializes. function getBridge(): SchedulerService | null {
function getBridge() { return satSchedulerWindow.trx?.modules?.scheduler ?? null;
return window.schedulerBridge || {};
} }
function getConfig() { function getConfig(): SchedulerConfig | null {
const b = getBridge(); const b = getBridge();
return typeof b.getConfig === "function" ? b.getConfig() : null; return b?.getConfig() ?? null;
} }
function getStatus() { function getStatus(): SatelliteSchedulerStatus | null {
const b = getBridge(); const b = getBridge();
return typeof b.getStatus === "function" ? b.getStatus() : null; return b?.getStatus() ?? null;
} }
function getBookmarks() { function getBookmarks(): SatelliteBookmark[] {
const b = getBridge(); const b = getBridge();
return typeof b.getBookmarks === "function" ? b.getBookmarks() : []; return b?.getBookmarks() ?? [];
} }
function markDirty() { function markDirty() {
var b = getBridge(); getBridge()?.markDirty();
if (typeof b.markDirty === "function") b.markDirty();
} }
function bmName(id) { function bmName(id: string): string {
const bm = getBookmarks().find(function (b) { return b.id === id; }); const bm = getBookmarks().find(function (b) { return b.id === id; });
return bm ? bm.name : String(id || ""); return bm ? bm.name : id;
} }
function escHtml(s) { function escHtml(s: string): string {
return String(s) return s
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function formatFreq(hz) { function formatFreq(hz: number): string {
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
return hz + " Hz"; return `${String(hz)} Hz`;
} }
// ── Satellite config helpers ────────────────────────────────────── // ── Satellite config helpers ──────────────────────────────────────
function getSatelliteEntries() { function getSatelliteEntries(): SatelliteScheduleEntry[] {
var config = getConfig(); const config = getConfig();
return (config && config.satellites && Array.isArray(config.satellites.entries)) return (config && config.satellites && Array.isArray(config.satellites.entries))
? config.satellites.entries ? config.satellites.entries
: []; : [];
} }
function ensureSatelliteConfig() { function ensureSatelliteConfig(): SatelliteScheduleConfig {
var config = getConfig(); const config = getConfig();
if (!config) return { enabled: false, pretune_secs: 60, entries: [] }; if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] }; if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
if (!config.satellites.entries) config.satellites.entries = [];
return config.satellites; return config.satellites;
} }
function collectSatelliteConfig() { function collectSatelliteConfig(): SatelliteScheduleConfig {
var enabled = dom.enabled ? dom.enabled.checked : false; const enabled = dom.enabled ? dom.enabled.checked : false;
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60; const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
return { return {
enabled: enabled, enabled: enabled,
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune, pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
@@ -107,12 +121,12 @@
// ── Render: section ─────────────────────────────────────────────── // ── Render: section ───────────────────────────────────────────────
function renderSection() { function renderSection() {
var config = getConfig(); const config = getConfig();
var satCfg = (config && config.satellites) || {}; const satCfg = config?.satellites;
var enabled = !!satCfg.enabled; const enabled = satCfg?.enabled ?? false;
if (dom.enabled) dom.enabled.checked = enabled; if (dom.enabled) dom.enabled.checked = enabled;
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60; if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60);
if (dom.body) dom.body.style.display = enabled ? "" : "none"; if (dom.body) dom.body.style.display = enabled ? "" : "none";
renderEntries(); renderEntries();
@@ -122,35 +136,35 @@
// ── Render: entries table ───────────────────────────────────────── // ── Render: entries table ─────────────────────────────────────────
function renderEntries() { function renderEntries() {
if (!dom.tbody) return; if (!dom.tbody) return;
var entries = getSatelliteEntries(); const entries = getSatelliteEntries();
var frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
entries.forEach(function (entry, idx) { entries.forEach(function (entry, idx) {
var tr = document.createElement("tr"); const tr = document.createElement("tr");
var tdSat = document.createElement("td"); const tdSat = document.createElement("td");
tdSat.textContent = entry.satellite || ""; tdSat.textContent = entry.satellite || "";
tr.appendChild(tdSat); tr.appendChild(tdSat);
var tdNorad = document.createElement("td"); const tdNorad = document.createElement("td");
tdNorad.textContent = entry.norad_id || ""; tdNorad.textContent = String(entry.norad_id || "");
tr.appendChild(tdNorad); tr.appendChild(tdNorad);
var tdBm = document.createElement("td"); const tdBm = document.createElement("td");
tdBm.textContent = bmName(entry.bookmark_id); tdBm.textContent = bmName(entry.bookmark_id);
tr.appendChild(tdBm); tr.appendChild(tdBm);
var tdEl = document.createElement("td"); const tdEl = document.createElement("td");
tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0"); tdEl.textContent = `${String(entry.min_elevation_deg)}\u00B0`;
tr.appendChild(tdEl); tr.appendChild(tdEl);
var tdPrio = document.createElement("td"); const tdPrio = document.createElement("td");
tdPrio.textContent = entry.priority || 0; tdPrio.textContent = String(entry.priority || 0);
tr.appendChild(tdPrio); tr.appendChild(tdPrio);
var tdActions = document.createElement("td"); const tdActions = document.createElement("td");
var editBtn = document.createElement("button"); const editBtn = document.createElement("button");
editBtn.className = "sch-write"; editBtn.className = "sch-write";
editBtn.type = "button"; editBtn.type = "button";
editBtn.textContent = "Edit"; editBtn.textContent = "Edit";
@@ -159,7 +173,7 @@
}); });
tdActions.appendChild(editBtn); tdActions.appendChild(editBtn);
var removeBtn = document.createElement("button"); const removeBtn = document.createElement("button");
removeBtn.className = "sch-write"; removeBtn.className = "sch-write";
removeBtn.type = "button"; removeBtn.type = "button";
removeBtn.textContent = "Remove"; removeBtn.textContent = "Remove";
@@ -178,12 +192,12 @@
// ── Render: pass status ─────────────────────────────────────────── // ── Render: pass status ───────────────────────────────────────────
function renderPassStatus() { function renderPassStatus() {
if (!dom.passStatus) return; if (!dom.passStatus) return;
var entries = getSatelliteEntries(); const entries = getSatelliteEntries();
if (entries.length === 0) { if (entries.length === 0) {
dom.passStatus.innerHTML = ""; dom.passStatus.innerHTML = "";
return; return;
} }
var status = getStatus(); const status = getStatus();
if (status && status.active_satellite) { if (status && status.active_satellite) {
dom.passStatus.innerHTML = dom.passStatus.innerHTML =
'<span class="sch-sat-active-badge">PASS ACTIVE: ' + '<span class="sch-sat-active-badge">PASS ACTIVE: ' +
@@ -196,37 +210,38 @@
} }
// ── Render: bookmark dropdown ───────────────────────────────────── // ── Render: bookmark dropdown ─────────────────────────────────────
function renderBookmarkSelect(selectedId) { function renderBookmarkSelect(selectedId: string | null): void {
if (!dom.bookmark) return; const bookmarkSelect = dom.bookmark;
dom.bookmark.innerHTML = '<option value="">— none —</option>'; if (!bookmarkSelect) return;
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
getBookmarks().forEach(function (bm) { getBookmarks().forEach(function (bm) {
var opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = bm.id; opt.value = bm.id;
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
if (bm.id === selectedId) opt.selected = true; if (bm.id === selectedId) opt.selected = true;
dom.bookmark.appendChild(opt); bookmarkSelect.appendChild(opt);
}); });
} }
// ── Entry management ────────────────────────────────────────────── // ── Entry management ──────────────────────────────────────────────
function removeEntry(idx) { function removeEntry(idx: number): void {
var sat = ensureSatelliteConfig(); const sat = ensureSatelliteConfig();
sat.entries.splice(idx, 1); sat.entries.splice(idx, 1);
renderEntries(); renderEntries();
markDirty(); markDirty();
} }
// ── Form: open ──────────────────────────────────────────────────── // ── Form: open ────────────────────────────────────────────────────
function openForm(entry, idx) { function openForm(entry: SatelliteScheduleEntry | null, idx: number | null): void {
editIdx = (idx != null) ? idx : null; editIdx = (idx != null) ? idx : null;
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite"; if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
if (dom.preset) dom.preset.value = ""; if (dom.preset) dom.preset.value = "";
if (dom.name) dom.name.value = entry ? (entry.satellite || "") : ""; if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
if (dom.norad) dom.norad.value = entry ? (entry.norad_id || "") : ""; if (dom.norad) dom.norad.value = entry ? String(entry.norad_id || "") : "";
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5; if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5);
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0; if (dom.priority) dom.priority.value = String(entry?.priority ?? 0);
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : ""; if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(entry.center_hz) : "";
renderBookmarkSelect(entry ? entry.bookmark_id : null); renderBookmarkSelect(entry ? entry.bookmark_id : null);
@@ -243,24 +258,26 @@
} }
// ── Form: submit ────────────────────────────────────────────────── // ── Form: submit ──────────────────────────────────────────────────
function onFormSubmit(e) { function onFormSubmit(e: SubmitEvent): void {
e.preventDefault(); e.preventDefault();
var satellite = dom.name ? dom.name.value.trim() : ""; const satellite = dom.name ? dom.name.value.trim() : "";
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN; const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
var bmId = dom.bookmark ? dom.bookmark.value : ""; const bmId = dom.bookmark ? dom.bookmark.value : "";
if (!satellite) { alert("Please enter a satellite name."); return; } if (!satellite) { satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" }); dom.name?.focus(); return; }
if (isNaN(noradId) || noradId <= 0) { alert("Please enter a valid NORAD catalog number."); return; } if (isNaN(noradId) || noradId <= 0) { satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); dom.norad?.focus(); return; }
if (!bmId) { alert("Please select a bookmark."); return; } if (!bmId) { satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" }); dom.bookmark?.focus(); return; }
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5; const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0; const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN; const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
var sat = ensureSatelliteConfig(); const sat = ensureSatelliteConfig();
var entryData = { const existing = editIdx !== null ? sat.entries[editIdx] : undefined;
const entryData: SatelliteScheduleEntry = {
id: existing?.id ?? `sat_${Date.now().toString(36)}`,
satellite: satellite, satellite: satellite,
norad_id: noradId, norad_id: noradId,
bookmark_id: bmId, bookmark_id: bmId,
@@ -271,11 +288,8 @@
}; };
if (editIdx !== null) { if (editIdx !== null) {
var existing = sat.entries[editIdx];
entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
sat.entries[editIdx] = entryData; sat.entries[editIdx] = entryData;
} else { } else {
entryData.id = "sat_" + Date.now().toString(36);
sat.entries.push(entryData); sat.entries.push(entryData);
} }
@@ -287,16 +301,19 @@
// ── Preset change handler ───────────────────────────────────────── // ── Preset change handler ─────────────────────────────────────────
function onPresetChange() { function onPresetChange() {
if (!dom.preset || !dom.preset.value) return; if (!dom.preset || !dom.preset.value) return;
var parts = dom.preset.value.split("|"); const parts = dom.preset.value.split("|");
if (dom.name) dom.name.value = parts[0] || ""; if (dom.name) dom.name.value = parts[0] || "";
if (dom.norad) dom.norad.value = parts[1] || ""; if (dom.norad) dom.norad.value = parts[1] || "";
} }
// ── Wire all events ─────────────────────────────────────────────── // ── Wire all events ───────────────────────────────────────────────
function wireEvents() { function wireEvents() {
if (eventsWired) return;
eventsWired = true;
if (dom.enabled) { if (dom.enabled) {
const enabledInput = dom.enabled;
dom.enabled.addEventListener("change", function () { dom.enabled.addEventListener("change", function () {
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none"; if (dom.body) dom.body.style.display = enabledInput.checked ? "" : "none";
markDirty(); markDirty();
}); });
} }
@@ -312,10 +329,14 @@
} }
// ── Public API ──────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────
window.satScheduler = { satSchedulerWindow.satScheduler = {
wireEvents: wireEvents, wireEvents: wireEvents,
renderSection: renderSection, renderSection: renderSection,
renderPassStatus: renderPassStatus, renderPassStatus: renderPassStatus,
collectSatelliteConfig: collectSatelliteConfig, collectSatelliteConfig: collectSatelliteConfig,
}; };
if (getBridge()) {
wireEvents();
renderSection();
}
})(); })();

Some files were not shown because too many files have changed in this diff Show More