Trial for Ride the Ridges 2026 #1

Merged
erestodo merged 6 commits from fixed-site-tracker into master 2026-09-19 05:05:00 +00:00
Owner

Will be testing this new idea for Ride the Ridges 2026

Will be testing this new idea for Ride the Ridges 2026
First step of the fixed-site tracker redesign. Purely additive: nothing
reads the two new settings yet.

Each flag now takes its default from the environment, so precedence is
flag > env > built-in and `--help` prints the value the daemon would
actually use rather than a built-in the environment has already
overridden.

APP_CALLSIGN (default N0CALL) and KML_DIR (empty = overlays disabled) are
the new settings; addr, aprs-server and log-level gain HAMTRAK_* names.
APP_CALLSIGN and KML_DIR stay unprefixed as specified; the rest are
namespaced so they cannot collide in a shared compose file.

A blank variable counts as unset. `FOO=` in a compose file means "I did
not configure this", and taking it literally would leave the daemon
listening on "" or logging in as "".

The callsign is validated through internal/callsign, the same rules the
UI enforces, so the environment cannot be a side door for a value that
would be rejected everywhere else. A bad one warns and falls back rather
than refusing to start: a tracker that comes up anonymous beats one that
does not come up. ValidCallsign is exported because --callsign bypasses
Resolve entirely and deserves the same check.

LookupFunc is injected rather than calling os.LookupEnv directly, so the
tests never fight over process-global state.
The daemon now owns its geography and its identity. Coverage is fixed at
43.9525,-91.601667 (EN43EW) with a 100 mi / 160.934 km radius, the
upstream connection opens at boot and is held for the life of the
process, and every SSE client receives the identical feed.

That deletes the entire per-client subscription machinery: buddySubs,
areaClients, clientsByID, indexClient/deindexClient, snapshotFor,
snapshotTargets, sendDeltaSnapshot, computeFilterSpec, specEq, the
grace-period stop timer, UpdateSubscription, POST /api/subscribe and
internal/stream/area.go. Commander goes with it -- with a static filter
the hub has nothing left to command, so main owns the aprsis lifecycle
and the hub is now purely a cache and a fan-out. Client shrinks to a
single channel.

BuildFilter now formats lat/lon to four decimals. At one decimal this
centre rendered as 44.0/-91.6, putting the requested circle 5.3 km north
of the one we filter against locally and silently starving its southern
rim. The upstream radius is also padded to 165 km so the feed is a
strict superset of the area we serve; the hub trims it back to the exact
circle with one haversine per packet.

The status event now carries the daemon's callsign and coverage centre.
The browser has no identity or viewport of its own any more, and putting
the circle on the wire keeps the frontend from hardcoding a second copy
of server config that could drift.

Verified against live APRS-IS: 30 stations cached, farthest 157.0 km
against the 160.934 km limit, none outside.

Two findings the verification forced:

APRS-IS refuses N0CALL outright -- "# Login by user not allowed", then
it closes the socket. The reconnect loop treated that as an ordinary
disconnect and retried forever, looking healthy from the outside while
no packet ever arrived. The client now recognises the refusal and the
never-acknowledged login, reports both at error level with the callsign
and a hint, and main warns at boot when the placeholder is in use.

/api/stream ignores query parameters rather than rejecting them, and
/api/subscribe answers 410. A tab loaded before this change still sends
the old query string and POSTs subscriptions; falling through to the SPA
handler would have handed it HTML and looked like success.
A station reaches a client only once it has reported speed > 0 at some
point inside the 60-minute window, and stays published while any moving
fix remains -- so a vehicle stopped at a light does not flicker off the
map, and one parked an hour ago disappears. Live sample: 30 stations in
range, exactly 1 of them moving.

Station now carries Track, every fix inside the window, oldest first.
Prev is gone; it was just Track[len-2], and two sources of truth for the
same fact is how a heading arrow starts disagreeing with the trail under
it. movingCount is a maintained counter rather than a rescan, so
qualification stays O(1) on both append and trim.

Only snapshot carries the trail. position events are deltas the client
appends to the copy it already holds -- resending an hour of history on
every beacon is roughly 40x the bandwidth for no new information. Which
makes a dropped position a permanent hole rather than a moment of
staleness, so a client whose buffer overflows is now flagged and
re-snapshotted instead of silently losing a segment forever.

Retention keys on the last position packet, not Curr.ReceivedAt: weather
reports bump that timestamp, which would have kept a station that
stopped moving alive indefinitely.

The sweep moved from every ingested packet to a 30s ticker. It is
O(stations) and the old placement made it the hot path of a firehose.
The ticker and the clock are injectable so the window is tested
deterministically rather than with sleeps.

Trails are capped at 240 fixes, but only 4 for stations that have never
moved. Most of a 100-mile circle is digipeaters, iGates and weather
stations that will never be drawn; paying 240 slots each for thousands
of them is the difference between a few MB and a few hundred.

New expire event. With a window and sticky qualification, stations leave
the published set, and the protocol had no way to say so -- a tab left
open would have slowly filled with vehicles that stopped existing hours
ago.

clone() deep-copies the trail before it leaves the Run goroutine. The
slice is trimmed in place, so aliasing it would race the handler's
json.Marshal. Verified under -race.

Also adds a skipped-by-default diagnostic that runs captured APRS-IS
lines through the parser and reports which report speed > 0. "Why is the
map empty?" is going to be a recurring question with this rule, and the
answer is usually that nothing is moving: a 90-second capture at 21:00
local had 15 positioned packets and none in motion.
Overlays become the daemon's rather than the browser's. An operator
drops files in KML_DIR, a scan every 30s picks up adds, edits and
deletes, and every connected client is told over SSE. Blank KML_DIR
disables the feature.

The id is a hash of the filename, so it survives restarts and edits and
changes only on rename. That stability is the whole point: the browser
hangs each overlay's visibility and colour off this id, and a
content-derived id would silently reset the user's choices every time a
file was touched. A separate rev, from mtime and size, is what tells the
client the bytes changed -- without it an edit in place would be
invisible and the stale overlay would render forever.

Three things the directory being operator-controlled does not excuse:

Files are held back until two consecutive scans agree on size and mtime,
so one still being copied in is never published, fetched, and failed to
parse as truncated XML. Costs up to 30s of latency; worth it.

Symlinks are skipped rather than followed. The directory's contents are
trusted, but a link is the one way they could point elsewhere entirely.

Nothing request-derived reaches the filesystem as a path. The id is
matched against ^[0-9a-f]{16}$ before any lookup, resolved through a map
the scanner built, and the file is then opened through os.Root so even a
corrupted mapping cannot escape the directory.

Verified live: a KML and a KMZ discovered and listed, the KMZ served
byte-exact as a valid zip with the right media type, traversal-shaped
and malformed ids all 400 without reflecting the input, an unknown but
well-formed id 404, and adds and deletes propagating within one scan.
Overlays now come from a local directory, so nothing in the daemon
fetches a URL a client chose. /api/proxy/kml and /api/geocode had no
callers left and are deleted along with the whole internal/proxy
package. That removes the only surface where a browser could make this
server open an outbound connection of its choosing.

The guard itself is kept, as internal/netguard. It is not wired into
anything today and is honest about that in its package comment, but the
check is easy to get subtly wrong -- unmapping ::ffff:127.0.0.1 before
judging it, catching NAT64 and 6to4 as ways to smuggle a v4 target,
running as a Dialer.Control hook so it sees the resolved address and
therefore survives DNS rebinding -- and the next URL-fetching feature
should not have to rediscover all of it. It keeps its tests, including
one that stands up a real loopback server and confirms a live dial is
refused, so it stays verified rather than rotting.

/api/subscribe, /api/proxy/kml and /api/geocode now answer 410 for one
release. A tab loaded before the redesign still calls them, and falling
through to the SPA handler would hand it an HTML page that looks like
success.

compose.yaml gains the overlay bind mount (read-only, and it must be
readable by uid 65532 since the container has no shell to chown
anything) and loses the comment claiming this image fetches arbitrary
URLs and talks to Nominatim, which is no longer true. README rewritten
for a fixed-site tracker: no callsign prompt, no buddy list, no place
search, a configuration table covering both flags and environment
variables, and a "why is the map empty" section -- because with a
moving-vehicles-only rule the honest answer is usually that nothing is
moving, and the next person to ask deserves the diagnostic rather than a
bug hunt.
feat(web): viewer-only frontend with breadcrumb trails
Some checks failed
Release (dev) / 🔍 Lint (push) Successful in 1m52s
Release (dev) / 🚀 Build and Publish (push) Failing after 1m12s
c084cfe7cf
The browser stops configuring anything. No callsign prompt, no viewport
filter, no uploads -- it opens the feed and draws what arrives.

sse.ts goes from ~270 lines to ~100. Everything about identity and
subscription is gone: openedWith*, sessionId, effectiveArea,
buildStreamUrl, prunePositionsForMode, postSubscription, reconcile, and
all three store subscriptions that drove them. What survives is one
EventSource, backoff reconnect, and five handlers. safeSubscriber stays
but now wraps the SSE handlers rather than store subscriptions; the
failure mode is unchanged, since those handlers call store setters whose
subscribers run Leaflet inside svelte/store's shared queue.

UserSetup, the user store and the area store are deleted, along with the
moveend -> area push in Map.svelte.

positions.ts learns the new protocol. applySnapshot replaces outright
rather than merging -- the merge existed to survive subscription-changing
pans, which no longer happen -- except for the single-station snapshot
that arrives when a vehicle first qualifies, which must not wipe the map.
applyDelta appends the incoming fix to the trail we already hold, since
position events carry no history. forget() handles expire, and
sweepStale on the 10s tick is the backstop for one that never arrived.
Heading now derives from the tail of the trail instead of a prev field.

Overlays invert: identity is the server's, presentation is the client's.
localStorage stores a prefs map keyed by the server's stable id instead
of the overlay list. Prefs for absent ids are deliberately never pruned,
so a file that briefly vanishes during an atomic write does not silently
reset the user's choices. The parsed cache is keyed by id AND rev, and
loadParsedFor drops earlier revisions -- keying on id alone is the easy
bug here, where the signature says rebuild and the rebuild quietly
re-renders the stale parse. Relative icon paths in plain KML can no
longer be resolved and say so; bundle a KMZ.

Trails render in a new pane at z-index 450: above every KML pane so a
route is never buried under a filled polygon, below markerPane so icons
and popups always win a click. Diffed inside the existing marker
reconcile so the two can never disagree about which vehicles exist,
gated on a length|first|last signature, and updated with setLatLngs
rather than recreated. White casing under a coloured core is what keeps
a 3px line readable over both pale road fill and dark forest; the colour
is per-callsign, because one accent for every vehicle makes crossing
trails unparseable.

The tracked list survives as a local lens. Markers dim rather than hide
-- hiding makes a live map look dead, removes the only way to discover a
callsign worth tracking, and throws away the spatial context that is the
actual question. Trails are the exception and are drawn only for tracked
vehicles, because a dimmed 60-minute polyline crossing thirty others is
just noise. setIcon is now gated on an icon signature so a fleet does not
rebuild every marker's DOM on every packet.

Sidebar starts collapsed, so the collapsed header carries the status dot,
the vehicle count and the overlay button. The callsign row and
buddy/area badge are replaced by the daemon's callsign and a filter
badge. Rows show speed, and a tracked callsign with no position says
"not currently in feed" -- with a moving-vehicles-only rule a parked car
dropping out is routine, and without the hint it reads as a bug.

The map frames the coverage circle and draws its boundary from the
status event, rather than the frontend keeping a second copy of where
this installation is pointed.

Verified against the running daemon with the SPA embedded: index serves,
status/snapshot/overlays all arrive, both a KML and a KMZ are discovered
and listed, and the compiled bundle contains api/overlays, expire,
hamtrak.overlayPrefs, is-dimmed and vehicle-trails while containing zero
occurrences of api/subscribe, api/proxy/kml, api/geocode, hamtrak.user
or hamtrak.area.
erestodo merged commit c084cfe7cf into master 2026-09-19 05:05:00 +00:00
erestodo deleted branch fixed-site-tracker 2026-09-19 05:05:00 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
erestodo/hamtrak!1
No description provided.