MARS APRS — README

MARS APRS System

Author: Doug Kaye (K6DRK) · Copyright: 2026 Doug Kaye. All Rights Reserved.

Version: Server & Displays (v1.23.0); Mobile App (v1.23.0); iGates (v5.2); Transcribers (v1.3) — see Versioning


Table of Contents

  1. Overview
  2. Versioning
  3. System Architecture
  4. NetBird VPN
  5. iGates (v5.2)
  6. iGate Diagnostics
  7. iGate Aggregation Relay
  8. The problem it solves · How it works · Why it runs on a VPS · Cloudflare DNS · Unique per-gate logins · Turning it on/off · Components
  9. APRS Server (v1.23.0)
  10. Cloudflare Tunnel
  11. Display Pis (v1.23.0)
  12. Running a display Pi on Starlink
  13. Mobile Apps (v1.23.0)
  14. Monitoring the whole event · Architecture · Location Sharing Flow · Smart Track · Building & Distributing · Background Location · Apple Watch Companion · Wear OS Companion
  15. Transcribers
  16. Calibration · Transcriber Diagnostics
  17. User Interfaces
  18. Authentication
  19. Analyzer
  20. Architecture · Authentication · Beacon Recording · Map & Controls · Key Files · Services · API Endpoints
  21. Backup, Recovery and Updates
  22. Server Pi · Display Pis · iGates
  23. Log Rotation
  24. Building & Deploying Devices
  25. Creating New Master Images
  26. Supporting Systems
  27. Appendix
  28. Testing

Overview

This document is intended for a technical audience who want to understand the inner workings of our APRS system. Users are encouraged to view the User Guide at https://marsaprs.org/userguide.html. For how to operate the admin pages — permissions, events, devices, tickets — see ADMIN.MD, served at /admin.html.

The MARS APRS System provides real-time position tracking for MARS (Marin Amateur Radio Society) public-service events. Operators carry APRS trackers that transmit their GPS position over radio. The system receives those transmissions, publishes them to a live web map, and gives net control a browser-based view of all tracker positions updated every five seconds.

Three Raspberry Pi device types work together. iGates are devices that receive APRS transmissions on 144.39 MHz and forward decoded packets to the APRS-IS internet network. The APRS Server pulls packets from APRS-IS, maintains tracker state, and serves the web map and all supporting tools. Display Pis are screens at net control and other locations that show the live map in fullscreen. Anyone is free to connect to https://marsaprs.org to view the current default event. We expect this will be used by aid station, rest stop and other personnel as well as anyone interested in the events.

All devices are connected over a NetBird WireGuard VPN, which provides remote SSH access, device health monitoring, and nightly configuration distribution — without requiring port forwarding or static public IP addresses.

Versioning

Four version numbers, which look like drift and are not. The rule is that things sharing a number are things that ship together, and everything else carries its own.

What Version Cadence
Server, Display Pis, web map, mobile apps 1.25.0+68 One release. They are one API contract and one deploy.
iGates 5.2 Independent. Its own image, its own nightly update.
Transcribers 1.3 Independent, and new.

Server, web and mobile share a number because they genuinely move together: a release changes WEB_VERSION in map/index.php, version in app/pubspec.yaml, and map/app_version.php in one go. The +N build number is required by App Store Connect to increase on every upload, so it advances faster than the marketing version and is bumped on every build rather than every release.

iGates keep their own line, and should not be folded in. The major version is a migration boundary — migrate-to-v5.sh exists because v4→v5 broke the device's on-disk layout — and renumbering to match the server would discard that meaning. More practically, an iGate does not change when the web app does. Dragging it to 1.23, 1.24, 1.25 while no iGate file is touched would stop the number answering the only question anyone asks of it: which build is on that Pi in the field. IGATE_VERSION in igate/auto-update.sh is the source of truth, and each gate stamps it into its own dashboard so it reports what it is actually running.

Transcribers start at 1.0 for the same reason, and because starting a brand-new device at 1.23.0 would assert twenty-three releases that never happened. VERSION in transcriber/bin/transcriber.py is the source of truth; auto-update.sh writes it to /etc/transcriber/version so a device can be identified without running anything.

None of these govern compatibility. That is API_VERSION / API_MIN_CLIENT in map/index.php:31, which is the wire-format contract and is deliberately decoupled from all of the above — a client is supported while the server's min_client is at or below the client's built-in version, regardless of what any marketing number says. Bump API_VERSION only on a breaking change to a response shape; additive fields do not require it. Version numbers are labels for people; this pair is the one the software reads.


System Architecture

APRS Radio (144.39 MHz)
      │
      ▼
┌─────────────────────┐
│  iGate  (×N)        │  Pi Zero 2 W · v5.1
│  RTL-SDR dongle     │
│  direwolf TNC       │
│  direwatch.py       │
└──────────┬──────────┘
           │ TCP 14580
           ▼
┌──────────────────────────────────────┐     ┌──────────────────────────────┐
│           APRS-IS Network            │◀────│  Mobile App  (iOS/Android)   │
│         noam.aprs2.net:14580         │     │  Flutter v1.25.0               │
└────────────────┬─────────────────────┘     │  TCP 14580 (inject position) │
                 │ TCP 14580                 └──────────────┬───────────────┘
┌────────────────▼─────────────────────┐                    │ HTTPS (map + config + session)
│       APRS Server  (aprs-pi)         │  Pi 4 · v1.25.0      │
│  aprsDaemon.php → trackers.json      │◀───────────────────┘
│  Apache + PHP · netbird/ · wifi/     │
│  marsaprs.org  (Cloudflare Tunnel)   │
└──┬───────────────────────────────────┘
   │ HTTPS via Cloudflare
┌──▼─────────────────────┐
│  Display Pi  (×2)      │  Pi 4 · v1.25.0
│  Chromium fullscreen   │
│  marsaprs.org          │
└────────────────────────┘

Component Runs on Purpose
direwolf iGate AX.25 TNC; decodes RF packets; forwards to APRS-IS
direwatch.py iGate Drives TFT status display
aprsDaemon.php Server Pulls from APRS-IS; writes trackers.json, igates.json, aidstations.json
index.php Server Serves the live map; JSON polling endpoint
tiles.php Server Map-tile proxy + cache; serves OpenStreetMap tiles from a pre-seeded permanent base plus an on-demand browse cache, so clients never fetch from OSM directly
admin/index.php Server Admin UI; event and tracker management
netbird-poller.py Server Polls all Pi devices over VPN; writes stats.json
wifi/ Server Master WiFi credential store; distributes to all Pis
Chromium Display Pi Fullscreen kiosk pointing at marsaprs.org
Flutter app iOS / Android Native map + location sharing client; injects APRS positions directly to APRS-IS

NetBird VPN

NetBird is a WireGuard-based mesh VPN. Every MARS Pi device — iGates at remote locations, the server, and display Pis — is enrolled in the same NetBird network and gets a stable private IP address (100.x.x.x range) that never changes regardless of which WiFi network the device is on.

Why NetBird:

By using NetBird addresses for all our devices, they can communicate with one another without regard to how they're connected to the internet. NetBird can find them wherever they are. It also allows us to reach them from a laptop or other NetBird-capable device for maintenance, diagnostics, etc. We specifically use NetBird addressing for gathering statistics and operational data from devices in the field.

The Problem with NetBird: In order for NetBird to keep track of devices, those devices must frequently announce themselves to the NetBird servers. Unfortunately, this generates a great deal of internet traffic -- more than the traffic generated by our devices themselves. In some permanent iGate locations we use cellular hotspots to connect to the internet. Our hotspot provider gives us 1GB/month free of charge. The problem is that the NetBird traffic can easily surpass that limit.

We have therefore implemented a system that allows us to turn NetBird on and off remotely. Each device contacts the marsaprs.org server every five minutes to see if should turn its NetBird service on or off. This is controlled by the WiFi admin page on our server. A user with the admin password can use this admin page to enable or disable NetBird on any remote device. The device will act on any changes at its next five-minute interval. Note that we generally keep NetBird off for any permanently located iGate that uses a cellular hotspot. This isn't necessary for temporary or "guerrilla" iGates since they're usually on for less than 24 hours per event.


iGates (v5.2)

An iGate receives APRS radio packets and forwards them to the APRS-IS network. Each iGate is a Raspberry Pi Zero 2 W with a USB RTL-SDR dongle listening on 144.39 MHz. An optional 1.3-inch TFT display shows boot status and countdown timers for fault conditions.

Architecture:

144.39 MHz RF
      │
      ▼
RTL-SDR USB dongle
      │
      ▼
direwolf  (TNC + iGate daemon)
      │
      ├──▶ APRS-IS  (noam.aprs2.net:14580)
      │      (or, if the aggregation relay is enabled on this gate, direwolf
      │       points at the local isproxy on 127.0.0.1 instead — see
      │       "iGate Aggregation Relay" below)
      │
      └──▶ direwatch.service  (display manager)
                 │
                 ├──▶ TFT display (status screens)
                 └──▶ UDP port 1235 → server (health monitoring)

igate-watchdog.sh  (cron, every minute)
      ├── SDR presence check → dw-nosdr.py (every 2 min, then 2-min countdown + reboot)
      ├── Decode check (every 10 min) → restart direwolf, then report a dead receiver
      └── Internet check (every 5 min) → dw-nointernet.py (2-min countdown + reboot)

Watchdog:

igate-watchdog.sh runs every minute via cron and handles four health checks:

Decode check

The SDR check proves the dongle is on the USB bus and direwolf is running. Neither proves anything is being received. An RTL-SDR's R820T tuner can stop locking while every other indicator stays healthy: the dongle stays enumerated, rtl_test exits 0 while printing [R82XX] PLL not locked!, rtl_fm reports the frequency it tuned to and the sample rate it allocated — and then delivers zero bytes, forever. direwolf reads nothing and stays active. The gate looks perfect from every angle and gates no packets. Recovery is a USB re-bind or a replug. We hit this twice in one day on the Transcribers, which use the same dongles.

So the watchdog also asks how long it has been since anything was decoded. It reads that from direwolf's own APRS traffic log (LOGDIR /home/pi/aprslogs, one line per received packet, pruned at 14 days by auto-update.sh): the newest file's mtime is the moment of the last decode. Received packets, not gated ones — the FILTER in direwolf.conf drops most traffic before it reaches APRS-IS, so a gated-packet count would read as silence on a healthy gate. /var/log/direwolf/console.log is used as a second opinion, since direwolf prints an audio level line for every frame it demodulates and the traffic log's mtime can lag on a quiet gate whose stdio buffer has not filled. The console log can only push the last-decode time later — it can prevent a restart, never cause one.

Thresholds. Six hours of complete silence triggers one direwolf restart; three further hours of silence after that restart ends the escalation. 144.39 here is never quiet for six hours — even a poorly sited gate hears a beacon within minutes — while the cost of being wrong is a few seconds of direwolf downtime. A receiver that died at 2 am is no worse for being found at 8 am than at 4 am.

It restarts once, not repeatedly. A restart resets both things being measured, so the window is anchored on the later of the last decode and direwolf's own start time (from ActiveEnterTimestampMonotonic, compared against /proc/uptime) — otherwise a gate in a quiet spot would be restarted every six hours forever. /tmp/igate-rx-restarted records the one restart, and only an actual decode clears it; no amount of elapsed time will. The markers live in /tmp, so the nightly reboot clears them and the worst case on a gate that genuinely hears nothing is one restart per day. The check is also skipped entirely while sdr-usb-test holds the SDR, during the boot sequence, and while a reboot is pending.

Log lines, in /var/log/direwolf/watchdog.log:

Line Meaning
Nothing decoded for 6h — restarting direwolf. Six hours of silence with the dongle present and direwolf running. direwolf (and with it rtl_fm, and the dongle's tuner) has been restarted once.
Decoding again N min after the restart — receiver recovered. Traffic returned. The gate was in the stuck-tuner state and the restart cleared it; nothing to do.
RECEIVER DEAD: nothing decoded in the 3h since direwolf was restarted… The restart did not help. The dongle is enumerated and direwolf is running, so this is hardware, not software — go power-cycle or replug the dongle. The watchdog will not restart direwolf again until something is decoded.

State files: /tmp/igate-rx.state (console log line count and when it last rose), /tmp/igate-rx-restarted (epoch of the one restart), /tmp/igate-rx-dead (set once the receiver has been reported dead, to keep it from being reported every ten minutes).

The watchdog suppresses all checks while dw-startup.py is running (boot sequence), while a reboot is already pending (/tmp/aprs-rebooting), or while sdr-usb-test is running (/tmp/sdr-usb-test.pause — see iGate Diagnostics below), so a diagnostic that owns the SDR is never fought by the watchdog restarting direwolf. TFT presence is detected by reading GPIO 23: pinctrl get 23 | grep -q hi.

Log: /var/log/direwolf/watchdog.log

iGate Diagnostics

Two tools help keep the fleet's receivers healthy — one automatic, one on demand. A third check, the watchdog's decode check, catches the receiver that has gone deaf while still reporting itself healthy; when it logs RECEIVER DEAD, sdr-usb-test below is the next thing to run.

Before any of that: decodes are not in the journal. direwolf-run.sh redirects direwolf's stdout to /var/log/direwolf/console.log, so journalctl -u direwolf carries only rtl_fm's startup chatter and never a single decoded packet. Grep the journal for audio level on a perfectly healthy gate and you get zero, which reads exactly like a dead receiver.

That cost an afternoon. A dongle was swapped, the journal showed no decodes, and the conclusion — that the new SDR needed a different driver — was wrong twice over: the gate had been decoding the whole time, and the packaged librtlsdr already supported the new hardware. A hand-built driver was installed over a working one on the strength of it.

So, in order:

Question Where the answer is
Is it decoding right now? sudo tail -f /var/log/direwolf/console.log — an audio level line per frame
How much has it decoded? /home/pi/aprslogs/<date>.log, one line per packet, and the file's mtime
Which stations, how many? awk -F, '{print $4}' /home/pi/aprslogs/<date>.log \| sort -u \| wc -l
Did rtl_fm start, and how? journalctl -u direwolf — tuner, sample rate, and errors, but no decodes

Unique stations is the better measure of a receiver than packet count: one nearby station beaconing every minute dominates a total and says nothing about how far the gate can hear.

SDR self-noise self-test (automatic). sdr-selftest.sh runs nightly (from auto-update.sh, before the 4:10 am reboot) and can also be run by hand (bash ~/sdr-selftest.sh). It briefly stops direwolf, sweeps 144–148 MHz with rtl_power, and measures the level of any internal birdie (self-generated spur) in the APRS guard band (144.37–144.42 MHz) relative to the surrounding noise floor. It grades the receiver GOOD / MARGINAL / BAD, writes the result to ~/selftest.json, and uploads it to the fleet dashboard at https://marsaprs.org/igate/selftest/. The dashboard lists each gate by callsign and name, its grade, guard-band spur in dB, noise floor, and last report time, and flags the quietest receiver as "Best." A MARGINAL/BAD grade usually means RF self-noise coupling into the SDR (shielding/placement) — but a flaky dongle or bad USB connection can fake it too, so reseat the dongle before assuming a shielding problem.

Flaky-USB dongle test, sdr-usb-test (on demand). Some RTL-SDR dongles have cracked USB-A solder joints or worn connectors and drop off the USB bus intermittently. On an iGate that shows up as "SDR Not Found" and reboot loops. sdr-usb-test isolates the dongle and watches the kernel USB log for spontaneous disconnects, then for disconnects you induce by wiggling the connector. It is standalone: it pauses the watchdog (/tmp/sdr-usb-test.pause), stops direwolf, and streams from the dongle to load the USB link like real operation, restoring everything on exit.

sdr-usb-test                 # 5-min passive test, then a 20-s wiggle test
sdr-usb-test --quick         # 60-s passive + 15-s wiggle (fast check)
sdr-usb-test --no-wiggle     # passive only (also auto-selected with no TTY)
sdr-usb-test 120 15          # custom passive / wiggle seconds

Verdicts: PASS — no disconnects in either phase (healthy); FAIL — drops on its own → replace the dongle; SUSPECT — stable at rest but drops when disturbed → reseat the plug/adapter, else replace. The source lives at igate/home/sdr-usb-test.sh in this repo; install.sh and auto-update.sh symlink it to /usr/local/bin/sdr-usb-test so it runs from anywhere. Log: ~/sdr-usb-test.log.

Key files on the iGate Pi:

File Location Purpose
direwolf.conf /home/pi/ TNC frequency, callsign, APRS-IS login, filter
configure.sh /home/pi/ Interactive configuration wizard
auto-update.sh /home/pi/ Nightly script + WiFi credential update
igate-watchdog.sh /home/pi/ Cron watchdog (SDR, decode, IP and internet checks)
direwatch.py /home/pi/direwatch/ APRS-IS connection + TFT display manager
dw-startup.py /home/pi/direwatch/ Boot sequence display (stats + SDR check)
dw-nosdr.py /home/pi/direwatch/ "No SDR found" countdown display
dw-nointernet.py /home/pi/direwatch/ "No internet" countdown display
StatsRequestListener.php /home/pi/ UDP responder for NetBird monitor
sdr-selftest.sh /home/pi/ Nightly SDR self-noise test → fleet dashboard (shared with the Transcribers)
sdr-usb-test.sh /home/pi/ (→ /usr/local/bin/sdr-usb-test) Flaky-USB dongle test (run over SSH)
isproxy.py / isproxy.json /home/pi/ Local APRS-IS failover proxy for the aggregation relay (ships inert; active only when enabled — see iGate Aggregation Relay)
.isproxy-enabled /home/pi/ Sentinel file that opts this gate into the aggregation relay

SSH: ssh pi@<ip> · Password: guacamole


iGate Aggregation Relay

This is an optional, opt-in subsystem that lets us see our own iGates' received traffic before the public APRS-IS network throws most of it away. It is off by default; a gate only participates when we explicitly enable it. Everything below was designed and first deployed in August 2026.

The problem it solves

The public APRS-IS network de-duplicates packets. When several iGates all hear the same station and forward the same packet, APRS-IS keeps only the first copy and credits only the first iGate (via the packet's "q-construct"). Every other iGate that heard that packet gets no credit for it.

The practical consequence: an iGate that is usually the second to hear traffic — or a receive-only gate in an area already well covered by others — looks idle on the public feed even when it is fully online and doing its job. We could not tell "this gate is dead" apart from "this gate is healthy but always beaten to the punch." For fleet monitoring, that distinction matters.

The aggregation relay fixes this by capturing our controlled gates' radio→internet stream before it reaches the public de-duplicator, so we see every packet every one of our gates gates, undeduped, and can prove each gate is alive.

How it works (the data path)

   RF 144.39 → direwolf → isproxy (127.0.0.1)  ── on each ENABLED iGate
                              │
                              │  primary: relay.marsaprs.org:14590   (our relay)
                              │  fallback: noam.aprs2.net:14580       (public APRS-IS)
                              ▼
              igate-isrelay  (DigitalOcean VPS, public IP)
                              │
                              ├──▶ APRS-IS (noam.aprs2.net:14580)   ← still really gates
                              │
                              ├──▶ igate_relay.json   {callsign: last_gated_unix_ts}
                              └──▶ capture.jsonl       every gated packet, undeduped
                                        │
              aprs-pi: igate-relay-sync.service  ── pulls igate_relay.json every 20 s
                                        │
                                        ▼
                          /var/www/html/igate_relay.json
                                        │
                          map/index.php merges it into ?json  →  web sidebar

In plain English:

  1. On an enabled gate, direwolf no longer talks to APRS-IS directly. Instead it connects to a tiny local proxy, isproxy.py, listening on 127.0.0.1. This is invisible to direwolf — it thinks it is talking to a normal APRS-IS server.
  2. isproxy forwards that stream to our relay on the VPS (its primary upstream). If the relay is ever unreachable, isproxy automatically falls back to the real public APRS-IS, so gating never depends on our server — the worst case is we lose the undeduped capture for that gate, not its ability to gate.
  3. The relay, igate-isrelay.py, does two things with each gate's stream: it records every gated packet (attributing it to the gating station via the q-construct), and it forwards the stream on to the real public APRS-IS so the gate keeps doing its normal job. Recording happens before that forward, so it is undeduped.
  4. The relay writes igate_relay.json (a simple {callsign: last-gated-timestamp} map, the same shape as the server's igates.json) and appends every packet to capture.jsonl.
  5. On the server Pi, igate-relay-sync.service copies igate_relay.json over every 20 s, and map/index.php merges it into the map feed so the sidebar shows the gate as active from relay data even when the public feed would show it idle.

Does the sidebar update for every beacon a gate hears? No. The timestamp only advances when the gate actually gates a packet to APRS-IS — not for packets it merely receives and then drops (non-position, heard via a digipeater, filtered out, or a direwolf duplicate). The relay also throttles its file write to at most once every 3 s, and the sync runs every 20 s, so the sidebar is a liveness indicator ("this gate is gating"), not a per-packet feed. The full per-packet, undeduped record is in capture.jsonl on the VPS.

Why it runs on a VPS (not at home)

The natural place for the relay would be the server Pi (aprs-pi) at home. It can't live there, and understanding why is the key to this whole design:

So the gates — which live on cellular connections all over the county — have no way to reach a relay hosted at home. The fix is to put the relay somewhere with a real, public, inbound-reachable IP: a small cloud server.

The VPS: a DigitalOcean droplet named aprs-relay, region SFO3, Ubuntu 24.04, the smallest tier (the relay is a trivial Python program). It has a public IPv4 and IPv6, which gates can reach directly with no NAT in the way.

Public IPv4 64.23.166.192
Public IPv6 2604:a880:4:1d0:0:3:3d0f:6000
Relay service igate-isrelay.service, runs as user isrelay from /opt/igate-isrelay/
Listens :14590, dual-stack (accepts gates over both IPv4 and IPv6)
Firewall ufw allows 22 (SSH) and 14590 (relay) only
SSH ssh [email protected] using the Mac's ~/.ssh/id_ed25519 key

Cloudflare DNS for the relay

Gates connect to the relay by the name relay.marsaprs.org, which points at the VPS:

Both are DNS-only (grey cloud), not proxied. This is important: Cloudflare's orange-cloud proxy only understands HTTP, so a proxied record would break the relay's raw TCP. The records live in the marsaprs.org zone and are edited from the Cloudflare dashboard, or via the API with a token scoped to Zone → DNS → Edit on that zone. Using a name (not the bare IP) means if the VPS is ever rebuilt, only these two records change and no gate needs reconfiguring.

Unique per-gate logins (required)

APRS-IS allows only one connection per callsign-SSID at a time; a second login with the same identity kicks the first. Historically our gates logged in with the bare base call (every MARS gate as MARS, each personal gate as its own base call). That was fine when each gate held a single direct connection, but the relay opens its own upstream per gate — so with a shared login those upstreams fight each other, flapping every second and even knocking field gates off their servers.

The fix is to give every gate a unique login = its full callsign with SSID (MARS-5, MARS-13, KI6RGP-10, …). The APRS-IS passcode is derived from the base call only and ignores the SSID, so the same passcode still worksMARS, MARS-5 and MARS-13 all use passcode 27888. A bonus: the public q-construct now shows the real gate (qAO,MARS-13) instead of a uniform MARS.

This is applied automatically:

The local proxy (isproxy) and failover

isproxy.py is a small stdlib-only asyncio program. direwolf connects to it on 127.0.0.1:14580 (set by IGSERVER 127.0.0.1 in direwolf.conf). It keeps one upstream connection at a time and pipes bytes transparently in both directions:

Configuration is isproxy.json; a live status file isproxy.status.json records which upstream is currently in use (primary/fallback).

Reconnect (fixed 2026-08-07, commit e77e8cc). The shutdown flag for a direwolf session was never cleared when that session ended, so every reconnect after the first was a no-op: handle_direwolf still ran — banner, cached login, synthetic logresp, which is why the logs looked half-alive — but the upstream supervisor's while not self.stop exited immediately and no upstream was ever opened. The gate then churned (connect, login, drop) every ~15 s without gating anything.

auto-update.sh restarts igate-isproxy before direwolf, so the proxy's first session is the outgoing direwolf, killed seconds later by that restart — meaning every nightly update wedged every relay-enrolled gate until someone restarted the service by hand. The fix clears the flag per session, with a session counter so a session that is closing down cannot clear it out from under a newer one that has already taken over.

igate/tests/test_isproxy.py drives two sequential direwolf sessions against a fake APRS-IS and asserts both reach the upstream; it fails on the old code and passes on the new. This matters most for the cellular gates, whose NetBird is disabled by policy most of the time — a wedged proxy there stops gating and leaves no way in until NetBird is toggled on from the server and the next 5-minute check-netbird.sh poll runs.

Turning it on or off for a gate

Activation is sentinel-guarded so nothing changes on a gate unless we ask. The proxy files ship to every gate but stay inert until the sentinel exists:

# Enable the relay on a gate:
ssh pi@<gate>  touch /home/pi/.isproxy-enabled
# then run the updater (or wait for the nightly run):
ssh pi@<gate>  sudo /home/pi/auto-update.sh

# Disable / roll back:
ssh pi@<gate>  rm /home/pi/.isproxy-enabled
ssh pi@<gate>  sudo /home/pi/auto-update.sh

When the sentinel is present, the auto-update.sh block: (1) promotes IGLOGIN to the unique login (above); (2) backs up direwolf.conf, rewrites IGSERVER to 127.0.0.1, saving the original for rollback; (3) enables and starts igate-isproxy; (4) restarts direwolf. When the sentinel is removed, the same block cleanly reverses all of that — restores the original IGSERVER, disables the proxy, and direwolf goes back to talking to APRS-IS directly. Because isproxy always falls back to public APRS-IS, enabling a gate before DNS/relay are reachable is harmless — it just gates the normal way until the relay is up.

Fleet-wide enrollment (central control). SSHing to each gate doesn't scale (and most of the fleet is only intermittently reachable over NetBird), so auto-update.sh also reads a single server-side flag, https://marsaprs.org/igate/isproxy-enroll.txt, and creates or removes the sentinel to match it — so the whole fleet enrolls or withdraws from one file, and each gate applies it on its next nightly run. The file holds one token per line:

ALL          # enroll the entire fleet
# MARS-3     # …or list specific MYCALLs, one per line, to stage a rollout
# MARS-5

ALL enrolls every gate; a list enrolls just those MYCALLs; commenting out everything (or an empty/unreachable file) is fail-safe — gates leave their current state untouched, so a network blip never mass-disables the fleet. To pause a rollout, comment out ALL. To roll the whole feature back, set the file to withdraw (no active tokens) and the gates disable themselves on their next update. Only v5 gates activate (the block is v5-guarded); on a v4 gate the sentinel is harmless. Because of the self-updating two-run pattern, a gate running an auto-update.sh that predates this flag picks the flag up the following night.

Components and where they live

Component Host Path / unit Role
igate-isrelay.py VPS aprs-relay /opt/igate-isrelay/ · igate-isrelay.service Aggregation relay: forwards each gate to APRS-IS under its own login, records undeduped
igate_relay.json VPS /opt/igate-isrelay/ {callsign: last-gated-ts}, pulled to the server
capture.jsonl VPS /opt/igate-isrelay/ Every gated packet, undeduped (for the analyzer, future)
isproxy.py / .json each iGate /home/pi/ · igate-isproxy.service Local failover proxy; ships inert, active only when enabled
.isproxy-enabled each iGate /home/pi/ Sentinel opting the gate in
igate-relay-sync.sh server aprs-pi /home/pi/ · igate-relay-sync.service Pulls igate_relay.json from the VPS every 20 s
relaypull key server → VPS ~/.ssh/relaypullrelaypull@VPS Least-privilege SSH key; a forced command lets it only read igate_relay.json
relay.marsaprs.org Cloudflare DNS A + AAAA, DNS-only Name the gates connect to

Repo sources: relay + its unit and the sync service are under server/bin/ and server/systemd/; the proxy, its unit, and the activation logic are under igate/home/, igate/systemd/, and igate/auto-update.sh.

Rollback of the whole feature: remove ~/.isproxy-enabled from every enabled gate and run their updater (returns them to direct gating); the relay and sync can then simply be stopped. No gate's ability to gate ever depended on any of this.


APRS Server (v1.23.0)

The server is a Raspberry Pi 4 running Apache and PHP. It receives APRS packets from APRS-IS, maintains live tracker state, serves the web map and admin tools, and hosts the NetBird monitor and WiFi credential manager. It is reachable at marsaprs.org via a Cloudflare Tunnel — no inbound port forwarding or static IP required.

Note that anyone can always view the then-current event at https://marsaprs.org. No pasword is required.

Architecture:

APRS-IS (noam.aprs2.net:14580)
      │ TCP
      ▼
aprsDaemon.php  ──writes──▶  trackers.json
      │                            │
      └── reads ──▶ config.yaml ◀──┘
            │  (symlink → events/<Event>/event.yaml)
            ▼
       index.php  ◀── browser polls every 5 s
            │
       admin/index.php  (event + tracker management)

netbird/daemon.php  ──UDP──▶  all Pi devices (port 1235)
       └──writes──▶  netbird/stats.json

wifi/index.php  (credential editor)
       └──▶  wifi.yaml  (downloaded nightly by all Pis)

Key files:

File Role
aprsDaemon.php Background daemon; APRS-IS connection; writes trackers.json, igates.json, aidstations.json
config.yaml Symlink → active event's event.yaml
trackers.json Live tracker state (daemon writes, browser reads)
igates.json iGate last-beacon timestamps (daemon writes, browser reads)
aidstations.json Aid station last-beacon timestamps (daemon writes, browser reads)
index.php Map page + JSON/config/history endpoints
admin/index.php Admin UI; all admin API endpoints
events/<Name>/event.yaml Per-event configuration
mobile_trackers.json Active mobile participant sessions (token, callsign, last update, pending_msgs queue, aprs_lat/aprs_lon/aprs_ts for APRS-IS dedup)
/var/lib/marsaprs/messages.db Messaging store (SQLite, event-scoped); written by ?messaging=send / ?mobile=message. Photo attachments under /var/lib/marsaprs/photos/<E>/. Replaces the old per-event messages.json.
/run/aprs/web_sessions.json Active web operator tokens (RAM disk; cleared on reboot)

Each event's configuration lives in events/<EventName>/event.yaml. config.yaml is a symlink to the active event's file.

Cloudflare Tunnel

All traffic to marsaprs.org is routed through a Cloudflare Tunnel. The Pi establishes an outbound HTTPS connection to Cloudflare's edge — no inbound firewall rules needed, and the Pi works on any network including cellular.

Browser → Cloudflare edge (marsaprs.org)
                  │ tunnel
          cloudflared (aprs-pi) → Apache :80

Tunnel ID: b11404f2-7497-4822-92b4-f75db418a1fe

Config at /etc/cloudflared/config.yml (repo: server/cloudflared/config.yml):

ingress:
  - hostname: marsaprs.org
    service: http://localhost:80
  - service: http_status:404

To reinstall with a new token:

sudo cloudflared service uninstall
sudo cloudflared service install <new-token>
sudo systemctl start cloudflared

The tunnel token is obtained from the Cloudflare Zero Trust dashboard: Networks → Tunnels → \<tunnel> → Configure → Install and run connector


Display Pis (v1.23.0)

A display Pi is a Raspberry Pi 4 running Chromium in fullscreen mode, pointed at marsaprs.org. It is a read-only display device — no long-term local configuration or data storage. Two display Pis are in use: NetControl (operator screen) and BigTV (large audience screen).

Architecture:

marsaprs.org  (via WiFi + Cloudflare)
      │ HTTPS
      ▼
Chromium  (fullscreen kiosk)
      │ localhost:8080
      ├──▶ kill-server.py   (handles /exit from kiosk mode)
      │
aprs-monitor.sh   (polls marsaprs.org every 30 s;
      │             relaunches Chromium via localhost:8080
      │             if server is unreachable)
wifi-watchdog.sh  (checks WiFi every 30 s;
                   calls wifi-restored.sh on reconnect)

Autostart: Chromium launches at boot via ~/start-kiosk.sh, called from the LXDE autostart file (~/.config/lxsession/rpd-x/autostart). The wrapper reads ~/autologin.txt, sets cursor size via XCURSOR_SIZE=48, and passes --user-data-dir=/tmp/chromium to keep Chromium cache/state in RAM (tmpfs).

Operator auto-login: If ~/autologin.txt exists, start-kiosk.sh appends ?autologin to the URL; if line 1 contains an operator name, it also appends &operator=<name> for automatic messaging subscription. A blank first line means "use this machine's hostname", so a fresh install is correct without anyone editing the file. The server handles ?autologin by setting a PHP session for the current event and redirecting to the clean URL, with messaging credentials embedded as JS globals for auto-subscribe.

configure.sh prompts for the operator name alongside the hostname and writes this file. That pairing exists because the two used to be set independently: a display renamed to NetControl on 2026-08-07 carried on announcing itself to the app as BigTV for the rest of the day, because nothing updated autologin.txt when the hostname changed. With two displays in the fleet, both claiming one operator name breaks messaging auto-subscribe.

Single kiosk instance: several things launch the kiosk — the LXDE autostart at login, aprs-monitor when it switches pages or finds no browser, and hands at the keyboard — and nothing used to stop them stacking up. Two browsers loading the same map means double the tile requests and double the CPU, which presents as a slow display on a perfectly healthy network. start-kiosk.sh now refuses to start a second instance (a pre-flight check plus flock to close the race). Chromium's own protection had been disabled by an rm of its Singleton files, which was itself aimed at ~/.config/chromium while the browser runs with --user-data-dir=/tmp/chromium — so it had been guarding nothing.

Counting kiosk instances is subtler than it looks. Matching --kiosk on the command line also matches Chromium's renderers and zygote, and the flock wrapper, since all of them carry the flag. A correct count requires the executable itself to be chromium and the process not to be a child (no --type=).

Cursor size: 48 px via XCURSOR_SIZE=48 in start-kiosk.sh (applied before Chromium), reinforced by ~/.Xresources (Xcursor.size: 48) loaded via @xrdb in the LXDE autostart.

SD card I/O: Chromium profile to /tmp/chromium (tmpfs); journald Storage=volatile keeps journal entries in RAM. Both configured via files.tar.gz and install.sh.

SSH: ssh pi@<ip> · Password: guacamole · VNC: vnc://<ip>:5901

Both display Pis share one Starlink terminal. A day of measurement (2026-08-07, ~300 samples at 1/min plus 20 instrumented reboots) produced the following, and the reasoning matters more than the settings because the symptoms are all misleading.

Diagnose power before anything else. A Pi 4 that reboots in a loop, reaching "Welcome to Desktop" and then resetting silently — no panic, no log, the journal simply stops — is browning out, not crashing. It fails at desktop start because that is peak draw: GPU, compositor, Chromium and WiFi all at once. Confirm from the hardware, not the symptoms:

/home/pi/power-check.sh     # the codified version of all of this — see below
vcgencmd get_throttled      # 0x0 = healthy
                            # bits 0-2 = happening now; bits 16-18 = since boot (latched)
dmesg | grep -i undervoltage

Two supplies were inadequate before one worked, and the second failed progressively (undervoltage events 3 → 6 → 12 in three minutes) without looping. Use a 61 W USB-C supply with a short, thick cable directly into a wall socket. Moving the SD card to a different Pi 4 changed nothing — the board was never at fault.

Strong signal does not mean a working link. The most expensive wrong turn of the day was trusting signal strength. A display measured -49 dBm, 72 Mbit/s negotiated and zero tx failures while showing 2.7-second round trips to its own gateway — every static indicator healthy, the link unusable. The cause was airtime contention: it sat 24 inches from another AP occupying the same 2.4 GHz channel. Relocating the display fixed it; no Pi-side setting could have.

The single most useful check: ping the device's own gateway from the device. A LAN hop must be under ~5 ms at 0% loss. That one number separates "the WiFi is broken" from "the WAN is slow" from "the server is slow", and nothing else does it as quickly.

Prefer Ethernet where it exists. A display wired to the router sidesteps this entire section — band steering, co-channel contention, roaming to a stronger AP, and WiFi/Bluetooth coexistence all stop applying. Route metrics handle it automatically (eth0 at 100 beats wlan0 at 600), and WiFi stays associated as a silent fallback if the cable is pulled. The WiFi logic below stands down on its own: wifi-band-pin.sh exits immediately unless wlan0 actually carries the default route, because every measurement it makes pings the default gateway and would otherwise be judging the radio using the cable's numbers.

Check the negotiated speed after wiring one up: dmesg | grep 'eth0: Link is'. A display here first came up at 10 Mbps half duplex — the signature of a damaged cable or bad crimp — and would have carried map tiles well enough to look fine while being one knock from dropping. A replacement cable gave 1 Gbps full duplex.

WiFi power save is disabled on every profile update-wifi.php creates. NetworkManager leaves it on by default, which parks the radio between beacons and delays inbound packets specifically — so a device stays reachable outbound while inbound traffic stalls, which looks like anything but a power-save setting. On a marginal signal it costs hundreds of milliseconds.

Band selection is measured, not assumed — see wifi-band-pin.sh. 5 GHz was unusable at this site (-73 dBm, 100% packet loss) despite negotiating 351–390 Mbit/s, while 2.4 GHz on a clear channel gave 0% loss at 3 ms. Starlink picks its own channels and moved between 1, 6, 11 and 153 during a single day, so neither band can be trusted in advance and a good choice does not stay good.

Warm boot is reliable; cold power-on is where faults appear. Twenty instrumented reboots produced a rendered map in 20–21 seconds every time — zero failures, zero kiosk restarts, zero roams. The startup failures that prompted the investigation were all cold power-on, which draws far more current than systemctl reboot. If a display misbehaves at startup, test with a cold power-cycle; a warm reboot will not reproduce it.

A cloned SD card carries the source machine's identity. It brings the NetBird enrolment (two peers sharing one identity flap endlessly), /var/lib/bluetooth/<adapter>/ (a Bluetooth mouse paired to the previous Pi fails with ConnectionAttemptFailed: Page Timeout, and no amount of resetting the mouse helps — the stale bond is on the Pi), plus hostname, autologin.txt, SSH host keys and machine-id. Prefer a fresh install.sh per device.

For details on using the map, see USERGUIDE.MD.


Mobile Apps (v1.23.0)

Native iOS and Android apps are available as an alternative to the web map. The apps provide the same live tracker display as the web map, and support background location sharing — GPS position continues to be reported even when the screen is locked or the app is not in the foreground.

Location: the app/ subdirectory of this repo (app/lib, app/ios, app/android, app/pubspec.yaml). It was merged in from the former standalone aprs-map repo, with history preserved.

Monitoring the whole event

Normally a phone sees only what was addressed to it. That is not a policy — it is how the messaging core works: the delivered feed is a join on deliveries, and a message not addressed to you has no row there. Transcriber log entries have no rows at all, so the radio has always been invisible to phones.

Three independent switches, behind one ⚙ gear on the Messages screen, in the sheet What you see and hear:

Switch Holds
Speak text messages every text message this phone shows is read aloud
Hear radio traffic the operators' own recordings, just after each over
Receive all messages everyone's traffic, not just yours

They are independent because following the event as text costs almost nothing and the audio is the part that costs cellular data — so nothing is ever implied.

Speech is one switch, not two. There were separate settings for messages addressed to you and for everybody else's, and that is a distinction the operator never had a reason to draw — a phone that reads your own messages aloud but sits silent through the rest of the net is not a state anyone chose, it is one they reached by finding only one of the two switches. Receive all messages already decides whether that traffic arrives, so speech has nothing left to qualify: if a text message is on this phone, it is spoken. That also retires the sheet's only dependent row, so nothing greys out or explains itself.

Getting there took two goes. It was two icons, a 🔊 speaker for sound beside the gear for the subscription, on the reasoning that what reaches this phone at all is not an audio setting. The reasoning is sound; the trade was not, because arranging the switches then took two panels and a row at the foot of each pointing at the other, and people went looking for the radio behind a speaker that did not own it.

The gear fills while any of the three is on, which keeps the one thing the speaker's crossed-out state was good for: the bar says whether this phone is following the event or about to make a noise, without opening anything.

Titles carry the meaning and only Receive all messages needs a subtitle. That is a constraint rather than a style — the sheet caps at 85% of screen height and clips the overflow silently, which once hid two rows entirely until somebody screenshotted it.

Monitored traffic never raises a notification. None of it was sent to this operator, and on a busy net that is a message every few seconds; a phone that buzzed for each would be unusable inside a minute. It is delivered as audio (subject to the same mute switch as everything else) and it goes nowhere near the watch — AppState.swift promises that every message the wrist holds gets announced, and a firehose would break that, churn the message cap, and let a push-to-talk reply aim at whichever stranger spoke last. The wrist still hears it all, because the phone plays it.

Radio entries are handled as sound, never as speech. A transcriber entry's text came from a speech model and is really carrier for the clip URL; reading it aloud was tried and is strictly worse than the recording — slower than the traffic it describes, and it states a mangled callsign in the same confident voice as a correct one. So a radio entry is played (addClip), and typed traffic is the only thing spoken (addSpeech). The exclusion is unconditional, covering the case where audio was wanted but unavailable — an entry whose clip never arrived, or a channel with send_audio off. Falling back to reading those aloud would reintroduce exactly the behavior that made a busy net unlistenable, and only sometimes, which is worse than never. See _handleMonitoredBatch in app/lib/map_screen.dart.

A spoken message is broken into phrases, with real silence between them. Two gaps, doing different jobs: 500 ms after the sender's name, because "From Dirck." run into the opening words loses both halves; and 250 ms between sentences, because the voice already pauses at a period on its own and a second full half-second on top reads as the speaker having lost their place rather than as punctuation. Each phrase is a separate utterance whose completion is awaited, which is what lets a gap land where it belongs at all.

The split is deliberately conservative, and the same in all four places that speak — splitSentences in map/utils.js and app/lib/speaker.dart, sentences in ios/WatchApp/Sources/Announcer.swift, and splitSentences in the Wear Announcer.kt. It requires two alphanumerics before the terminator, so J. Kaye stays one phrase, and whitespace after it, so 146.520 is never split down the middle — the case that matters most on a channel where reciting a frequency is much of what gets said. An abbreviation (Mt. Tam) does split; that is the accepted cost, a quarter second in the wrong place. The rules are asserted in map/tests/js/utils.test.js and app/test/speaker_test.dart, over the same cases, so the four cannot drift apart.

On both watches the gap travels with the utterance rather than being a rule inside the drain loop, because the boundaries within one announcement do not all mean the same thing — and a rule keyed on position could not tell them apart once a message with no sender label shifted everything up by one. The Stop control's countdown counts the gaps too: a four-sentence message is a second of silence on its own, and that countdown is the only thing telling an operator whether to wait it out.

Everything audible goes through one queue (app/lib/audio_queue.dart) — spoken messages, radio clips, and the alert tone alike. There were two before and they did not know about each other, so on a net with speech and radio both on, a synthesised voice and a real one came out of the speaker at the same time and neither could be understood. Three rules are the whole design:

  1. Nothing interrupts. A new transmission waits for the current one to finish. Chopping a clip mid-word to start another is worse than a few seconds' wait, and rule 2 bounds how far behind that can put you.
  2. Five minutes, measured by when it was said — not when it was queued. That distinction is the entire point after an outage, where everything is queued at the same instant and a queue-time rule would consider none of it stale and read out an hour of backlog. It is also the only version explicable in one sentence: you will not hear anything more than five minutes old.
  3. It can always be stopped. A Stop control appears while anything is queued, showing both a count and a duration — neither answers "wait or stop it" alone, since three long overs and thirty short ones are the same count and very different waits. The automatic rules are judgement, and judgement is sometimes wrong.

Silent text is deliberately not bounded by any of this. A message nobody has to listen to costs nothing to deliver, so every one still arrives and lands in the thread; only what is audible is rationed.

The alert tone is part of the queued item, not the notification. It used to be the iOS notification's own sound, with speech delayed 900 ms to let it finish — a race against a sound the app neither schedules nor can observe, and it lost: the tone arrived on top of speech that had already started. The notification is now silent and the tone is the first half of the queue entry, which makes the order a fact rather than a bet. It also closed a gap: the notification sound was the one noise in the app exempt from the queue, so it could fire in the middle of a radio clip.

A message is marked read when it has been spoken, not when it arrived. Marking on arrival claimed the operator had heard something the queue could still drop as stale or fail to play — and it ran before the audio session was known to be working, which is exactly when it was wrong. Spoken aloud is read: leaving it unread meant a message already heard in full still sat behind a red badge, and opening it to clear that badge was the act that read it aloud a second time.

Audio is pulled, never pushed, and never in bulk. A clip is fetched when you tap it. The transcription is what you follow; the audio answers "what did they actually say" about the one line in fifty that came out garbled, and pre-fetching the other forty-nine is data spent on clips nobody plays.

Catch-up after an outage is bounded and says so — "42 monitored messages skipped" rather than a silent hole where half an hour of the net used to be.

The Admin "Hide" toggle applies to both clients. index.php?json has always carried a hidden flag per tracker; the app simply never read it, so a tracker hidden from the web map kept its marker on the phone. TrackerData.showsOnMap (position and not hidden — kept distinct from hasPosition, since a hidden tracker still has a position and the drawer still reports its age) now gates the marker, the drawer keeps the entry dimmed, and a tracker hidden while selected has its selection and breadcrumb trail dropped together — otherwise a trail is left drawn to nothing. Hiding is about map clutter, not reachability: a hidden tracker remains addressable in messaging on both clients.

App Architecture

The app is a Flutter application with a hybrid architecture:

┌────────────────────────────────────────────────┐
│                 Flutter App                    │
│                                                │
│  ┌──────────────────────────────────────────┐  │
│  │  WebView  →  marsaprs.org/index.php      │  │  reads tracker data, config, courses
│  └──────────────────────────────────────────┘  │
│                                                │
│  ┌───────────────────┐  ┌────────────────────┐ │
│  │  Native Drawer    │  │  BackgroundLocation │ │
│  │  menu_drawer.dart │  │  Service           │ │
│  │  tracker_layer    │  │  background_       │ │
│  │  .dart            │  │  location.dart     │ │
│  └───────────────────┘  └────────┬───────────┘ │
└────────────────────────────────  │  ───────────┘
                                   │
              ┌────────────────────┼──────────────────────┐
              │ TCP 14580          │ HTTPS                 │
              ▼                    ▼                       │
    APRS-IS (noam)        marsaprs.org                    │
    inject position       ?mobile=join/update/leave       │
    packet                heartbeat + session mgmt        │
File Purpose
lib/map_screen.dart Root widget; hosts the WebView, JS bridge, native tracker/breadcrumb overlay
lib/help_screen.dart Built-in Quick Start guide; displayed on first launch; accessible via Help → Quick Start in the drawer footer
lib/menu_drawer.dart Native Flutter sidebar drawer; Help footer button shows modal (app info + Quick Start/User Guide/ticket buttons)
lib/tracker_layer.dart Native map marker overlay; squares for mobile, circles for fixed; ID label next to each dot
lib/arrow_painter.dart CustomPainter for breadcrumb directional arrows; isolated to avoid Path<LatLng> collision
lib/course_layer.dart Course polyline overlay; fetches all courses in parallel
lib/background_location.dart GPS stream; _heartbeatTimer (timed uploads) + _maybeUploadFromStream() (distance-triggered); calls MobileSession
lib/background_task_handler.dart Android foreground service stub; no-op handler; keeps process alive
lib/mobile_session.dart HTTP client for ?mobile=join/update/leave API
lib/aprs_client.dart Legacy — TCP socket to APRS-IS; no longer used (server-side injection replaced direct TCP)
lib/remote_config.dart Polls ?config endpoint; parses event configuration, including offline_map.url (the tile source)
lib/map_config.dart Constants: server URL; default map-tile URL (the MARS tile proxy)
android/app/src/main/AndroidManifest.xml Android permissions
ios/Runner/Info.plist iOS background mode declaration

Offline Maps & Tile Proxy

Both the on-screen base map and the offline download pull map tiles from the MARS tile proxy (marsaprs.org/tiles.php/{z}/{x}/{y}.png) rather than from OpenStreetMap directly. This was introduced in 1.20.2 to fix first-run offline-map downloads: OpenStreetMap blocks bulk tile downloads per-IP, so the app's download of an event area would fail. Routing everything through our own server means each tile is fetched from OSM at most once (server-side, with a proper User-Agent), which both keeps the app working and is far gentler on OSM than every client fetching its own tiles.

Server side (map/tiles.php) serves each tile from, in order:

  1. tiles/base/ — a permanent, pre-seeded cache of event areas (e.g. Marin). Never auto-deleted; this is what offline event downloads pull from, so those downloads never touch OSM.
  2. tiles/cache/ — an on-demand "browse" cache, filled the first time anyone pans to an area outside the seeded regions.
  3. OpenStreetMap — on a miss, fetched once, cached into tiles/cache/, and returned. If OSM is unreachable a transparent tile is returned with a short cache time so it retries soon.

Seeding (map/tiles-seed.sh <minLat> <maxLat> <minLon> <maxLon> <minZoom> <maxZoom>) politely fetches a lat/lon box into the permanent base cache (single-threaded, small delay, proper User-Agent). Marin z10–14 (tiles-seed.sh 37.80 38.25 -123.05 -122.30 10 14, ~1,300 tiles) is seeded.

Cleanup (map/tiles-clean.sh, nightly cron at 04:17) trims the browse cache — deletes tiles not re-fetched in 90 days — which bounds the cache size while keeping popular areas fresh; it never touches tiles/base.

Config override: the tile URL is the server config's offline_map.url, which index.php?config defaults to the proxy. An event can point both the display layer and the offline download at a different source by setting offline_map.url in its config. The app reads it via remote_config.dart; the compiled fallback lives in map_config.dart. Because the display and download URLs match, downloaded tiles render from the shared offline cache (FMTC keys tiles by URL).

Location Sharing Flow

Both iOS and Android use the same upload path: position data is POSTed to the MARS server, which injects the APRS packet via its own persistent TCP connection to APRS-IS.

User taps Share Location
  → _ensureBackgroundPermissions()     (Android: notification + battery opt;
                                        iOS: verify Always location permission)
  → MobileSession.join()               POST ?mobile=join {name, pin, sharing_mode: "unknown"}
      ← {token, callsign, passcode}    e.g. callsign=K6DRK-01
  → BackgroundLocationService.startTracking()
      → Geolocator.getPositionStream() (single stream; AppleSettings on iOS with
                                        allowBackgroundLocationUpdates: true;
                                        AndroidSettings on Android)
  → Android only: FlutterForegroundTask.startService() — persistent notification;
                  background_task_handler.dart is a no-op stub
  → Upload triggered by _heartbeatTimer (timed) OR GPS event (distance ≥ threshold):
      Both platforms:
        _heartbeatTimer fires at configured interval
        _maybeUploadFromStream() on every GPS event (uploads if moved ≥ threshold
            OR configured interval elapsed)
        → MobileSession.update(lat, lon)    POST ?mobile=update {token, lat, lon}
              ← 200 ok  (or 404 → stops sharing)
              → server injectAprsPacket() via local TCP 14580
                    → "K6DRK-01>APRS,TCPIP*:!3751.72N/12232.64W>Mobile/Alice\r\n"
  → aproDaemon receives packet from APRS-IS
      → distance filter: skip if new position < 100 ft from last breadcrumb
      → writes K6DRK-01 to trackers.json with mobile=true
  → WebView polls ?json every 5 s → map updates

App restart while sharing was active:
  → resumeSharing() reads SharedPreferences (token, callsign, interval, activity mode)
  → Attempts token reuse via MobileSession.update(); if stale, re-joins silently
  → Sharing resumes with same callsign and activity mode; snackbar notifies user

App Update Check

On launch the app fetches https://marsaprs.org/app_version.php (a small JSON manifest carrying the latest build per platform) and, if a newer build is available, shows a dismissable "Update available" prompt — never a forced upgrade. iOS opens the App Store listing (store_url; the check is a silent no-op while that URL is empty), Android opens the APK download (apk_url). A "Later" choice is remembered until an even newer build ships. Client logic lives in lib/update_check.dart; bump build/latest in map/app_version.php when a new app version is released (see the version-bump checklist).

Smart Track

Smart Track is the automatic beacon-interval algorithm in the native app (iOS/Android) and the web map. It monitors GPS speed and adjusts the upload frequency without any input from the user. (The Apple Watch and Wear OS companions do messaging only — they never beacon position.)

Unknown (?) mode — startup phase:

Every session begins in unknown mode (shown as ? in the web sidebar). The app joins the server with sharing_mode: "unknown" and stays in this phase until Smart Track collects enough GPS readings to make a confident initial determination — typically within 90 seconds. When the mode is first set, the app waits 15 seconds before sending the first real-mode beacon so the sidebar briefly shows ? before the actual activity icon appears.

Speed thresholds:

Mode Speed Interval
Stationary ≤ 1.0 m/s (2.2 mph) 120 s
Walk / Run ≤ 4.5 m/s (10 mph) 60 s
Cycle ≤ 11.0 m/s (25 mph) 30 s
Drive > 11.0 m/s 15 s

Debounce — preventing false mode changes:

A mode switch only occurs after a run of consecutive GPS samples all agree on the new mode. The required run length: - Walk/Run/Cycle/Drive: 15 consecutive samples - Stationary: 20 consecutive samples, plus at least 5 minutes of wall-clock time since last movement

A timer also fires every 30 seconds to catch the case where the device stops moving but GPS events stall. If no movement has been detected for 5 minutes (or 90 seconds during startup) the mode switches directly to stationary without waiting for GPS events.

Startup fast-window:

During the first 10 GPS readings of a new session, only 3 consecutive matching samples are needed to set the initial mode (instead of 15–20). This lets Smart Track make a confident initial guess within 30–60 seconds of starting rather than waiting several minutes. Once those 10 readings have been processed, the full debounce requirements apply.

GPS noise defense:

Two mechanisms prevent GPS jitter from causing false mode transitions:

  1. Accuracy gate: Any GPS reading with horizontal accuracy worse than 20 m is excluded when updating the last-movement timestamp. A brief poor-fix cannot reset the stationary timer.
  2. Movement confirmation guard: Three consecutive readings must all exceed the stationary threshold (> 1.0 m/s with accuracy ≤ 20 m) before the last-movement timestamp is updated. A single speed spike from GPS noise cannot prevent the stationary timer from eventually firing.

Implementation:

The algorithm runs identically in lib/map_screen.dart (Flutter iOS/Android) and index.php (web JS). Key functions: _processSpeedSample / processSpeedSample / _autoDetectFromSpeed for sample-based detection; _checkStationaryByTime / checkStationaryByTime for the timer-based fallback.

Building & Distributing

iOS

cd ~/marsaprs/app
flutter pub get
flutter build ios --release --no-codesign

Open ios/Runner.xcworkspace in Xcode. To distribute via TestFlight:

  1. Bump version in pubspec.yaml (e.g. 1.14.0+2 — the build number after + must increase with each upload).
  2. flutter build ios --release --no-codesign
  3. Xcode → Product → Archive → Distribute App → TestFlight (internal).
  4. Wait for the build status in App Store Connect to leave Processing before testers can install it. This typically takes 5–15 minutes.

Testers must have TestFlight installed (free, from the App Store). They install the app by opening the invitation email and tapping View in TestFlight. New testers may be prompted for a redeem code from the invitation. For updates, testers open TestFlight and tap Update.

Android

cd ~/marsaprs/app
flutter pub get
flutter build apk --release

The signed APK is at build/app/outputs/flutter-apk/app-release.apk. This is a universal APK (arm64-v8a, armeabi-v7a, x86_64 in one file) — do not use --split-per-abi for direct download, since the recipient would have to know their device's architecture and the wrong pick fails to install.

Publishing it — copy to the Pi under a versioned name; the download page picks up the newest automatically:

cp build/app/outputs/flutter-apk/app-release.apk ~/Downloads/aprs-map-<version>-<build>.apk
rsync -avz ~/Downloads/aprs-map-<version>-<build>.apk [email protected]:/var/www/html/android/

Publishing the watch app — the :wear APK is published from the same directory under its own aprs-wear- prefix. Build it as described in Wear OS Companion, then:

cp build/wear/outputs/apk/release/wear-release.apk ~/Downloads/aprs-wear-<version>-<build>.apk
rsync -avz ~/Downloads/aprs-wear-<version>-<build>.apk [email protected]:/var/www/html/android/

Ship both together. They carry the same applicationId and the same signing key, and a watch running an older build against a newer phone is exactly the drift the shared wire format exists to prevent.

URL Purpose
https://marsaprs.org/android/ Landing page — version, size, SHA-256, install steps for both apps
https://marsaprs.org/android/download.php Permanent phone download link; 302s to the newest aprs-map- APK
https://marsaprs.org/android/watch.php Permanent watch download link; 302s to the newest aprs-wear- APK

map/android/ holds index.php, download.php, watch.php and _apk.php; the APKs themselves are gitignored and live only on the Pi. The filename must match aprs-map-<major>.<minor>.<patch>-<build>.apk (or aprs-wear-… for the companion) or it is ignored, and the highest build number wins. The prefix is what keeps the two apart — the same applicationId means a watch APK served as the phone download would be offered as an update to the phone app.

Sideloading is a real limitation for the watch, not a detail. Play delivers a companion to a paired watch automatically, but only when the phone app was itself installed from Play; a sideloaded phone app has no such channel. So the watch APK has to be pushed across with ADB — from a phone using a tool like Wear Installer 2, or from a computer over Wi-Fi. The watch's uses-feature android.hardware.type.watch (required, wear/src/main/AndroidManifest.xml:5) means a phone refuses to install it, which is the guard that stops a user who taps the wrong download from replacing their phone app with a watch UI. The stable URL redirects rather than being a fixed filename that gets overwritten, so Cloudflare and browser caches can't pin an old release to it — and old versions stay downloadable at their own paths.

Release signing requires android/key.properties (gitignored):

storePassword=<password>
keyPassword=<password>
keyAlias=<alias>
storeFile=<absolute path to .jks or .p12>

android/app/build.gradle.kts reads this file automatically. If it is absent or incomplete, a release build fails rather than falling back to debug signing — an APK signed with the wrong key installs fine on a clean device and is rejected as an update for every existing user, who would have to uninstall (losing their tracker token and registration) to take it. Debug builds are unaffected and need no keystore.

The canonical copy predates the merge of the standalone aprs-map repo into this one and, being gitignored, did not travel with it:

cp ~/aprs-map/android/key.properties app/android/key.properties

Always verify a release before publishing it — the certificate is the only thing that distinguishes a correct APK from a broken one:

apksigner verify --print-certs <apk> | grep SHA-256
# expected: 0d30a9f258e1330cb22a092ca6df65af5d3bf085905fb9bc01d74e54c866da57
#           CN=Doug Kaye, OU=MARS, O=W6SG, L=Marin, ST=CA, C=US

compileSdk note: objectbox_flutter_libs in ~/.pub-cache may hardcode compileSdkVersion 31. Patch it to 36 to match build.gradle.kts.

To distribute: share the APK via Google Drive or email. Testers tap the download link, open the file with Package Installer, and if Android warns about an unknown source, tap More detailsInstall anyway.

Submitting to Google Play Store

One-time setup:

  1. Create a Google Play Developer account at play.google.com/console ($25 one-time fee).
  2. Generate a signing keystore (only done once — never change it after first publish): bash keytool -genkey -v -keystore ~/aprs-map-release.jks \ -alias aprs-map -keyalg RSA -keysize 2048 -validity 10000 \ -dname "CN=Doug Kaye, OU=MARS, O=W6SG, L=Marin, ST=CA, C=US"
  3. Create android/key.properties (gitignored — back up the passwords and .jks file securely): storePassword=<store-password> keyPassword=<key-password> keyAlias=aprs-map storeFile=/Users/doug/aprs-map-release.jks

Each release:

  1. Bump version in pubspec.yaml (e.g. 1.21.0+121.21.1+13 — the build number after + must increase with each upload).
  2. Build a signed App Bundle (AAB): bash flutter build appbundle --release Output: build/app/outputs/bundle/release/app-release.aab
  3. Build the Wear OS companion, which flutter build does not touch — it is a plain Gradle module, not a Flutter target: bash cd android JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \ ./gradlew :wear:assembleRelease JAVA_HOME is not optional: the only JDK on PATH is Java 1.8, which Gradle 9 / AGP 9 refuse to run under. Output: build/wear/outputs/apk/release/wear-release.apk — under app/build, not app/android/wear/build, because the Flutter Gradle plugin redirects every subproject's build directory. It takes its version from the same pubspec.yaml, so step 1 covers both. See Wear OS Companion.
  4. In Play Console → Your appTesting → Internal testingCreate new release → upload the .aab and the watch APK into the same release. Play delivers the watch APK to a paired watch by matching package name and signature, so both have to be in the release together; uploading only the bundle leaves existing watches on the old build with no indication that anything happened.
  5. After internal testing, promote the release through Closed testing → Open testing → Production using the Promote release button.
  6. Google typically reviews new production releases within 1–3 days.

Store listing assets required (one-time, update as needed): - Short description (80 chars), full description (4000 chars) - At least 2 phone screenshots (minimum 320px on shortest side) - Feature graphic: 1024 × 500 px PNG or JPG - App icon: 512 × 512 px PNG (must match the launcher icon) - Content rating questionnaire (set category to Utilities or Tools)

Submitting to Apple App Store Connect

One-time setup:

  1. Enroll in the Apple Developer Program at developer.apple.com ($99/year).
  2. In App Store Connect (appstoreconnect.apple.com) → Apps+New App:
  3. Platform: iOS
  4. Bundle ID: org.w6sg.aprsmap (must match ios/Runner.xcodeproj)
  5. SKU: aprsmap (any unique string)
  6. In Xcode → Signing & Capabilities: set Team to your Apple Developer account; let Xcode manage provisioning profiles automatically.

Each release:

  1. Bump version in pubspec.yaml (build number after + must increase with each upload).
  2. Build the iOS app: bash flutter build ios --release --no-codesign
  3. Open ios/Runner.xcworkspace in Xcode.
  4. Product → Archive. When complete, the Organizer opens automatically.
  5. Click Distribute App → App Store Connect → Upload.
  6. In App Store Connect → Your app → TestFlight: the build appears within minutes; full App Review takes 5–30 minutes before testers can install it.
  7. To submit for App Store production review:
  8. Go to Your app → App Store → + Version
  9. Fill in "What's New", select the build, submit for review.
  10. Apple review typically takes 1–3 days.

Store listing assets required (one-time, update as needed): - App description, keywords, support URL, marketing URL - Screenshots for iPhone 6.9" display (required) and iPad 13" (required if supporting iPad) - App icon: 1024 × 1024 px PNG (no alpha channel) - Privacy policy URL (required for apps that collect location data)

Background Location

Background location is implemented differently on each platform because of how each OS handles suspended processes.

Android

Two foreground services run while sharing: GeolocatorLocationService (from the geolocator package) keeps the GPS stream and Dart isolate alive; FlutterForegroundTask (from flutter_foreground_task) provides a persistent notification and prevents Android from killing the process. The FlutterForegroundTask handler (background_task_handler.dart) is a no-op stub — all beaconing is done by the main isolate via _heartbeatTimer and _maybeUploadFromStream(). A persistent notification is required — without it Android 13+ treats the service as having no notification and kills it when the screen locks.

Required permissions in AndroidManifest.xml:

Permission Why
INTERNET Network access — not injected automatically in release builds
ACCESS_FINE_LOCATION GPS
ACCESS_BACKGROUND_LOCATION Location while screen is locked (Android 10+)
FOREGROUND_SERVICE Run a foreground service
FOREGROUND_SERVICE_LOCATION Foreground service of type location (Android 14+)
WAKE_LOCK Keep CPU active between GPS fixes
POST_NOTIFICATIONS Show the foreground service notification (Android 13+); without this the service is killed on screen lock
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS Prompt the user to exempt the app from battery killing

At runtime, _ensureBackgroundPermissions() in map_screen.dart requests POST_NOTIFICATIONS and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS before starting a sharing session.

Samsung One UI is particularly aggressive — users must also set the app to Unrestricted battery mode: Settings → Apps → APRS Map → Battery → Unrestricted.

iOS

Info.plist declares UIBackgroundModes: location. The CLLocationManager flag allowsBackgroundLocationUpdates is set to true via AppleSettings(allowBackgroundLocationUpdates: true) in the geolocator stream configuration. "Always" location permission is required — "While Using" is not sufficient. When active, a white location arrow appears in the iOS status bar.

Single GPS stream — critical constraint: geolocator_apple only supports one active event-channel listener at a time. If a second Geolocator.getPositionStream() call is made while one is already active, PositionStreamHandler.onListenWithArguments returns an error and the second call silently fails — allowsBackgroundLocationUpdates is never set to true on the underlying CLLocationManager, and the background location indicator never appears. The fix: startTracking() in BackgroundLocationService owns the single Geolocator stream (with AppleSettings), and the map's blue-dot layer subscribes to _bgLocation.positionStream (a Dart broadcast stream fed by that same stream) rather than opening its own.

Upload mechanism: Both iOS and Android use the same _heartbeatTimer for timed uploads and _maybeUploadFromStream() for distance-triggered uploads. On iOS, the GPS event stream keeps the Dart isolate continuously alive, so Timer.periodic fires reliably. _maybeUploadFromStream() provides an additional trigger: it uploads immediately when the device has moved ≥ the configured distance threshold since the last upload, resetting the timer to avoid a duplicate beacon shortly after.

Why both platforms use server-side APRS injection: Both iOS and Android POST position data to ?mobile=update rather than sending raw TCP packets directly to APRS-IS. On iOS, NSURLSession-based HTTP is explicitly supported for background network tasks while raw dart:io Socket TCP connections are not reliable in background. Unifying Android to the same path keeps all beaconing logic in the main isolate, makes background_task_handler.dart a no-op, and simplifies the overall architecture.

Apple Watch Companion ("Watch")

A watchOS companion that makes the wrist a nearly hands-free extension of the phone: a reply is a press-and-hold push-to-talk. It does messaging only — it never beacons position.

The phone is the loudspeaker; the watch is the microphone. That is the opposite of how this was first built, and the inversion came from field testing. watchOS lets an app play audio only while it is frontmost, so a backgrounded watch cannot alert anybody — while the phone, in a pocket, can both chime and read a message aloud. So the phone announces whenever the watch app is not on screen, and the watch's job the rest of the time is to be ready to take a reply.

A lowered wrist is not backgrounded, and assuming otherwise cost this app most of its usefulness on the wrist. "Frontmost" was read as scene phase .active, so the watch declared itself mute the instant the screen dimmed and handed every announcement to the phone — which is most of a net, because a lowered wrist is the normal way to wear a watch. The claim was inherited from documentation and never measured. Announcer.runDimmedAudioTest() (Settings → Diagnostics on the watch) measured it on this hardware and disproved it:

app INACTIVE · session granted · spoke · 4.2s

A dimmed always-on display is still frontmost: the audio session is granted and the utterance completes. AppState.canAnnounce is therefore true for .inactive as well as .active, and false only for .background. The test button stays in the app as the regression check, because the failure it guards against — the watch promising to speak and going quiet while the phone defers to it — is silent on both devices at once.

This is why canAnnounce is now separate from isActive. One flag was answering two questions: "may I speak?" (anything but .background) and "should I run my own HTTP poll?" (only .active — a battery question, since the poller runs every few seconds and only while the phone is unreachable). Merging them meant a change made for one reason silently moved the other.

Who announces an arriving message is decided by the watch and obeyed by the phone. It is reported rather than inferred because the two devices had different notions of "awake": a dimmed watch is unreachable over WatchConnectivity yet still frontmost, so each assumed the other had it and messages were announced twice or not at all. Every relayed message also carries phoneAnnounced, which stops the watch re-reading a backlog when queued transfers flush on waking, and lets it raise a notification if it went quiet after promising to speak.

The watch reports two facts, not one, and the phone needs both. canAnnounce answers "am I on screen?". It does not answer "did I make a sound?" — and for a long time the phone decided on the first while needing the second. Silent Mode, the cover-to-mute gesture, a session the OS refuses, and on Wear OS a watch with no speaker or no speech data at all, every one of them leaves the app frontmost and completely inaudible. The phone deferred to it anyway, so the operator heard nothing from either device with nothing anywhere to say why — on a net, the worst failure in the system, and precisely the one this arrangement exists to prevent. So audioUnavailable travels with canAnnounce in a single payload (never two, or the phone could hold a fresh value of one beside a stale value of the other) and watchWillAnnounce requires both. It is retrospective by nature: whether the session will be granted cannot be known until something is announced, so the first message after audio goes away is still lost to the wrist. What it fixes is every message after it.

The wrist wins whenever it can be heard, and that is a deliberate ranking rather than an accident of which code path ran first. Distance beats loudness in the environment this exists for — a phone in a pannier is muffled by fabric before volume enters into it, while a watch is a hand's width from the ear with a clear path. The alert should also land on the device that takes the reply, so hearing a call and answering it is one motion instead of two, and the haptic and the voice should come from the same limb rather than making the operator triangulate.

Why it is native Swift. Flutter does not target watchOS, so the watch app cannot be Dart. It is a native SwiftUI target (WatchApp) inside the same app/ios/Runner.xcodeproj, bridged to the Flutter app over WatchConnectivity plus a Flutter method/event channel.

Path Purpose
app/ios/WatchApp/Info.plist Must live at this exact path. flutter_tools detects a watch companion by reading ios/<TargetName>/Info.plist for WKCompanionAppBundleIdentifier, so the directory name and the target name have to match (WatchApp)
app/ios/WatchApp/WatchApp.xcconfig #includes Flutter/Generated.xcconfig so the watch app's version comes from pubspec.yaml, exactly like the iPhone app. Project-level configurations carry no baseConfigurationReference, so without this the watch target would inherit no Flutter build settings
app/ios/WatchApp/Sources/ SwiftUI app, WatchConnectivity session, announcer (haptic/tone/TTS), dictation, direct messaging client
app/ios/Runner/WatchBridge.swift Phone side of the bridge: WCSessionDelegate + the org.marsaprs/watch method and event channels
app/lib/watch_bridge.dart Dart side of the bridge; reuses MessagingClient for all network traffic

Transport is hybrid. The phone is the fast path — it already polls, so it relays inbound messages to the watch and sends the watch's replies. The phone also hands the watch its tracker token, so when the phone is unreachable the watch polls ?messaging=poll directly over WiFi or LTE. Both paths converge on one ingest() on the watch that dedupes by message id, so a message can never be announced twice. The watch never polls while the phone is reachable.

Two build-system consequences. Once a watch companion exists, flutter_tools omits -sdk from the Xcode invocation and stops narrowing ARCHS/ONLY_ACTIVE_ARCH: simulator builds now need an explicit -d <device-id>, and debug builds are slower. Device builds are unaffected. app/TESTFLIGHT.md carries the release-time verification steps.

Do not move the "Embed Watch Content" build phase. It sits immediately after Resources in the Runner target, and it has to stay before Thin Binary. Thin Binary runs xcode_backend.sh embed_and_thin and declares ${TARGET_BUILD_DIR}/${INFOPLIST_PATH} as an input, which makes Xcode take a directory-tree signature of the whole of Runner.app. Scheduling a copy into Runner.app/Watch/ after that closes a dependency loop and the build dies with Cycle inside Runner. The watch target also sets SKIP_INSTALL = YES so the archive contains only Runner.app, with the watch app nested inside it.

Reading aloud on the phone needs UIBackgroundModes: audio. Without it iOS refuses to start an audio session once the app leaves the screen, which is the situation the feature exists for. One shared Speaker (app/lib/speaker.dart) owns the app's only text-to-speech engine — the chat screen used to own a second, and two contend for the same audio session and cut each other off mid-sentence. Note that defaultToSpeaker must not be passed with the playback category: it is valid only with playAndRecord, and including it fails the whole category call, leaving the app silent in the background with no error anywhere obvious.

The reply follows the traffic, from whichever side heard it. An arriving message re-aims the destination, so the announcement the operator just heard is also a statement of where their answer will go. The phone does this in WatchBridge._aimAt when it relays; the watch does it in AppState.ingest for anything it fetched by direct poll, which is the case the phone never sees. Broadcasts move the aim only when there is an addressable sender key — the phone has one, the watch does not, and aiming at the broadcast thread instead would send a spoken "copy that" to every tracker. A destination is also dropped when the conversation behind it is absent from the current event's list, which is what stops a thread from a finished event surviving into the next one.

Unconfirmed sends are visible and actionable. Outbox persists every reply until the server confirms it, across app launches and watch reboots — a message spoken into the wrist and silently lost is the worst failure this app has. A badge on the Talk page shows the count, and it is a link: tapping it lists what was said and offers Retry or Discard. Retry aims at the current destination, not the original, because an entry usually failed precisely because its target had gone; it also takes a fresh client id, since ?messaging=send carries none and the server therefore cannot dedupe a retry against a delivered-but-unacknowledged original.

The limitation accepted. watchOS only lets an app play audio while frontmost, and will not run a timer for a backgrounded one. (Frontmost includes a dimmed screen — see above — so this bites only when the operator has actually left the app.) So with the phone off or out of range and the watch app backgrounded, nothing reaches the operator until they raise their wrist — at which point the watch polls and announces what it missed. The only mechanism that would change this is a WKExtendedRuntimeSession, which costs heavy battery and a background-mode declaration App Review may query; the deliberate decision is to live without it, since the phone covers every case in which it is alive.

Wear OS Companion ("Wear")

The same watch app for Android watches. Same behaviour, same rules, same wire format — an operator moving from an Apple Watch to a Pixel Watch mid-season should not have to learn anything, and a bug fixed on one wrist should not survive on the other.

It is a second native app, not a shared one, for the same reason the first one is native: Flutter targets neither watch platform. The design is therefore stated once and implemented twice, and each file names its counterpart in a header comment so a change made in one place is findable in the other.

Path Purpose
app/android/wear/ Gradle module :wear — Compose for Wear OS app, Data Layer client, announcer (haptic/tone/TTS), push-to-talk, direct messaging client
app/android/app/src/main/kotlin/org/w6sg/aprsmap/watch/WatchBridge.kt Phone side of the bridge: the Data Layer clients plus the org.marsaprs/watch method and event channels
app/android/app/src/main/kotlin/org/w6sg/aprsmap/watch/WearListenerService.kt Receives from the watch when the phone app is not running
app/lib/watch_bridge.dart Dart side — one file, both watches. It has no Platform.isAndroid branch and must not grow one

One Dart bridge, two native ones. watch_bridge.dart speaks the same method channel, the same event channel and the same payloads to both platforms; every difference between WatchConnectivity and the Wear Data Layer is absorbed on the native side. That is what keeps the rules that matter — who announces, where a reply is aimed, what counts as stale — written down once. A platform branch in the Dart would be the start of two subtly different watch apps that nobody could keep in step.

Transport mapping. The Data Layer has three clients where watchOS has one session, and mapping them wrongly produces a watch that works on the bench and goes silent in the field:

watchOS Wear OS Why
sendMessage MessageClient/aprs/live, /aprs/tx Fast, and fails outright rather than queueing when the other side is not connected
transferUserInfo DataClient item at /aprs/msg/<id>, /aprs/txq/<uuid> Durable, survives both processes dying, starts the other side's listener service to deliver. A unique path per item, because the Data Layer only notifies on change — two identical payloads on one path is one event, and the second vanishes
updateApplicationContext DataClient item at /aprs/context One fixed path, latest value wins, syncs to a watch that was not running
transferFile Not needed; see push-to-talk below
isPaired / isWatchAppInstalled / isReachable CapabilityClient + node.isNearby There is no session to ask. The phone advertises aprs_map_phone, the watch aprs_map_wear, each in its module's res/values/wear.xml. Those two strings are wire contract: rename one and both apps keep running, neither reports an error, and the watch never hears anything again

Everything crosses as a JSON string under a single json key rather than as a typed DataMap, because DataMap.getInt on a value the sender wrote as a long returns zero rather than failing — the same class of trap that forces hand-written decoding on the WatchConnectivity side, and zero is a valid conversation id.

Push-to-talk transcribes on the watch, and that is the one real divergence. watchOS has no speech recogniser, so the Apple Watch records AAC, ships the clip to the iPhone, waits, and gets text back — three hops, a TalkSession with a "Sending audio…" phase, and nothing at all when the phone is away. Wear OS watches recognise speech themselves, so SpeechCapture.kt records nothing and transfers nothing: press, speak, release, send. It prefers createOnDeviceSpeechRecognizer where the watch has it, for the same reason the iPhone sets requiresOnDeviceRecognition — this is a ham operator's net traffic and there is no reason to hand it to Google if the wrist can do the work. The consequence that matters during an event is that push-to-talk keeps working when the phone is out of range, which is precisely when somebody is most likely to be talking into their wrist. There is therefore no talkAudio path, no transcript reply and no microphone permission on the phone side of this bridge.

The gesture is a raw pointer loop, not detectTapGestures or a long-press detector. Both of those decide for themselves when a press has become something else — a tap detector cancels once the finger drifts past the touch slop — and on a moving vehicle the finger always drifts. Down starts it, up ends it, nothing in between.

Ambient is not backgrounded, and the same two questions are kept apart here as on watchOS. canAnnounce is true while the Activity is STARTED, ambient included: a dimmed watch is still this app on the display and audio focus is still granted, and a lowered wrist is the normal way to wear a watch. isActive — which gates the direct poller, a battery question — is true only while RESUMED and out of ambient. Settings → Diagnostics carries the same Test audio when dimmed button as the Apple Watch, and it matters more here rather than less: Wear hardware varies far more than Apple's does, several models have no speaker at all, and the failure it guards against (the watch promising to speak while the phone defers to it) is silent on both devices at once.

The limitation accepted, and it is a different one. WatchConnectivity relaunches the whole iOS app in the background to deliver a watch message, so the iPhone can act on a reply spoken into the wrist even with the app closed. On Android, WearListenerService starts a bare process with no Flutter engine in it and nothing will create one. So a reply that reaches the phone while the phone app is not running is parked on disk and replayed the next time Dart calls ready. The watch does not pretend otherwise: if no sendResult comes back within thirty seconds the Outbox row moves from Sending to Waiting to send, where it is visible and can be retried. It is deliberately not re-sent down the watch's own direct path — ?messaging=send carries no client id, so the server cannot dedupe, and a retry of something the phone already sent would put the message on the net twice.

Build and release. :wear is a separate APK with the same applicationId and the same signing key as the phone app — Play matches a companion by package name and signature, and getting either wrong ships a watch app that installs cleanly and is never delivered to a watch. Its version comes from pubspec.yaml, parsed in wear/build.gradle.kts, exactly as WatchApp.xcconfig arranges on the other platform; local.properties is not used, because it is only written as a side effect of the last flutter build and is stale on a clean checkout. The versionCode carries a +100000 offset, since every APK in one Play release needs its own and both come from the same pubspec number.

cd app/android
# JAVA_HOME is required — the JDK on PATH is 1.8, which Gradle 9 / AGP 9 will not run under.
JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" \
  ./gradlew :wear:assembleRelease
apksigner verify --print-certs ../build/wear/outputs/apk/release/wear-release.apk | grep SHA-256
# expected 0d30a9f258e1330cb22a092ca6df65af5d3bf085905fb9bc01d74e54c866da57 — the same
# certificate as the phone APK

R8 is on for the watch release and off for the phone's. Unminified, Compose plus play-services-wearable is 22 MB of dex; minified it is under three. That is a storage question on a device with very little, and an install-time question on a link that is Bluetooth. Keep rules for the three manifest-declared classes live in wear/proguard-rules.pro.

The launcher icon is generated, not drawn. wear/icon/make-icon.py emits ic_launcher.svg and renders one PNG per density into wear/src/main/res/mipmap-*/; run it after any change and commit the result. It is an adaptive icon (mipmap-anydpi-v26/ic_launcher.xml), which is the only kind Wear OS 3+ uses — the layers are 108 dp, the system shows the middle 72 dp masked to a circle, and only a centred ⌀66 dp circle is guaranteed visible. The phone's square icon cannot simply be copied across: it fills its canvas edge to edge, so the circular crop slices the outer arcs off at both ends. The watch version is a recomposition against that geometry, using the phone icon's own sampled palette — the arcs pulled inside the safe circle, the sky and the massif extended outward to fill the margin the mask eats.

The whole scene sits in the background layer with an empty foreground, which is deliberately not the usual logo-over-fill split. The antenna stands on the summit, and a launcher that shifts the two layers independently for parallax would float it off the peak. One layer cannot come apart.

The three numbers that decide whether an edit survives: at x=250 the crop circle spans y 294–730, so a ridge line that looks right on the square canvas can be entirely outside the visible area; the mountain's tonal ramp runs across y 599–880 rather than the full height, or the crop shows only its top third and the massif reads as one flat shape at 64 px; and each density is rendered from the vector at its native size rather than downsampled from a master, because the arcs are thin and the stars are two pixels across.


Transcribers

A Transcriber is a Raspberry Pi (4 or 5) listening to a conventional receiver on a voice frequency, transcribing each transmission with whisper.cpp and appending it to the event log by itself. Net control hears everything on the radio and writes down almost none of it; this is the part that writes it down.

This was an RTL-SDR until 2026-08-29. The radio's own squelch now gates the audio and a USB sound card carries it to the Pi, which removed the software squelch, the gain calibration and the three filters built on top of them — about 680 lines that existed to answer, badly, a question a squelch answers in hardware. The cost is frequency agility: one receiver, one frequency, tuned by hand. See transcriber/HARDWARE-RECEIVER.md for the measurements and the traps. The SDR version is preserved at git tag pre-hardware-receiver, and the iGates are still SDR-based — none of this touches them.

146.700 MHz RF                       arecord -D plughw:2,0 -f S16_LE -r 16000 -c 1
      │                                    │  raw S16LE
      ▼                                    ▼
  Receiver ─── Pi 5 ──── [email protected]
                              │  AudioGate: 200 Hz high-pass, rms,
                              │  -55/-60 dBFS hysteresis, 1.2 s hang
                              ▼  one wav per transmission
                         whisper.cpp (tiny.en / base.en)
                              ▼
                POST index.php?messaging=log   →   "Radio → Log"

One receiver, one frequency. [email protected] is still a systemd template and the plumbing still allows several instances, each on its own capture device — but the frequency is set by hand at the radio, so a second one means a second radio and a second sound card rather than a second dongle in the same Pi.

The channel id no longer carries the frequency. It was Transcriber-146700, derived from the device and what it was tuned to, which went stale the moment the receiver moved — and the id names the systemd unit, the spool directory and the author of every log entry. It is now just the device name, stored rather than derived.

A channel is a participant. It is registered as kind = 'transcriber' and identified exactly the way a mobile is — a token matching no participant row is looked up in the channel registry and registered in the current event (_msg_resolve_sender). ?messaging=log accepts an operator or a transcriber; nothing else changed, because a log entry was already a message with no recipients. Channels write and never read: the log thread is listed only for operators, and the recipient pickers list only operators and mobiles, so a channel cannot be messaged and sees no traffic. display_name is the channel label, which is what makes an entry read 146.520 → Log with no client change anywhere.

Most of the worker is code that refuses to log things. whisper does not go quiet when it hears nothing — fed squelch hiss it produces "Thank you." with complete confidence — so clips under 1.2 s never reach it, a denylist catches what it invents anyway, and anything over two minutes is treated as a stuck carrier and discarded rather than occupying the channel for minutes. A log quietly filling with invented lines is worse than one that misses a transmission, because nobody thinks to question it. Entries queue on disk and flush in order, stopping at the first failure so a later one cannot overtake an earlier.

A channel can send the audio with the entry. The Audio checkbox in the manager encodes each transmission it logs and posts the clip alongside the transcription, so someone on the phone can hear what was actually said on a line that came out garbled. Off by default: it costs the receiver a little upload per transmission and is only worth it on a channel somebody is actually following on a phone.

AAC-LC in an .m4a, not Opus. Opus is the better codec and would be the obvious pick — but Apple does not decode Ogg Opus through AVFoundation, which is what the phone app's player uses on iOS, so on this fleet Opus is the format that might not play at all. AAC costs about 15 kB for a five-second over against Opus's 10, which is the right trade on something already trivially small.

Nothing about the audio may cost a transmission. A missing ffmpeg, an encoder that refuses the clip, a file gone missing under a waiting outbox entry — each logs the text on its own and says so once. The transcription is the record; the recording is a check on it. Clips ride in the outbox as a path rather than as bytes, on the card beside the entries that name them, because an entry may wait there for hours across an outage and a reboot is exactly what the outbox exists to survive.

The clip is posted before the transcription exists. log_audio writes an audio-first entry with empty text and returns an entry_id; the later log call completes it. That ordering is worth the extra round trip because whisper is the slow part — audio reached a listening phone in about 6 seconds instead of 26, which is the difference between hearing a net and reading its minutes.

Levels are measured and corrected, not left to loudnorm. Clips off the SDR arrived with a 37 dB spread between transmissions, so following a net meant riding the volume control. The first attempt used ffmpeg's loudnorm, which does the wrong thing on a mostly-silent clip: it dutifully amplified a −73 dBFS near-silent capture to −1.5 dBFS, a burst of hiss at full volume. What ships instead measures the clip and applies a single clamped gain toward −20 dBFS (AUDIO_MAX_GAIN_DB 30, AUDIO_MAX_CUT_DB 15), refuses to touch anything below AUDIO_SILENCE_DBFS (−65), and ends in a limiter. The spread across a real net came down to about 1.5 dB.

On the server the clip lands inside the web root at an unguessable name and is served by Apache with Cache-Control: public, immutable — no PHP in the path. Fifty hands-free phones each fetching every clip of a busy net is on the order of ten thousand mod_php invocations an hour, in bursts, which is not what belongs in front of an SD card. There is no multicast over HTTP; edge caching is the substitute, and it means the Pi serves each clip roughly once however many phones want it. That is available only because amateur transmissions are public by law — photos are private and do not move; they stay outside the web root behind ?messaging=photo. Clips expire after six hours; the log entry does not.

Callsigns are what a net log most needs right, and what whisper is worst at. One station on this receiver came back as K-60RK, K-6 DRK, 6 delta rho mu and K-60 Arcade — all four are K6DRK. So the worker fixes them up, in five layers, and stops at the first that fits: a correction somebody wrote down for this exact mishearing; exactly a callsign or phrase this event knows; close enough to one of them and to nothing else; something with the shape of a US callsign (one or two letters, a digit, one to three letters), which is what collapses "whiskey six sierra golf" into W6SG; and otherwise the words exactly as they were heard.

A correction is the only layer that is told rather than worked out, which is why it outranks the rest and why it is the only one that never matches loosely. It is a rule — "whatever this normalizes to, write that instead" — and a fuzzy rule would let one typo'd entry rewrite unrelated traffic into whatever its author had in mind, which nothing else here can do. It also has to run before the layers below rather than after: those run over the same words, and letting them go first would consume the text the rule names, so the rule would silently never fire — which is precisely the failure somebody typed it to fix.

That last layer is the point rather than the fallback. A wrong callsign in a log is worse than a mangled one — it reads as authoritative, it points at the wrong person, and nobody has any reason to doubt it, while K-60RK warns you itself. So nothing guesses: not on a near miss, not when two roster entries sit the same distance away, and never over text that already reads as a legal callsign, because the roster is not the band and a visiting K6DRJ must not be filed as the club's K6DRK.

Knowing the event's roster is what makes the middle two layers safe at all: it turns "did I hear a callsign" into "which of these thirty-five did I hear". It arrives as a vocabulary key beside channels in the same config the channels come from, since it belongs to the event rather than to any one receiver, and it is routinely absent — an event with no roster is the normal case, and there only the shape layer applies, which can do no more than join up what was already said. Correction runs after the filters above have accepted an entry, never before: they are calibrated on what whisper emits, and a roll call corrected first reads as the same six words four times over and is thrown out as a loop.

The last sentence gets a period, and nothing else is punctuated. whisper punctuates its own output and is good at it — it hears the pauses in a transmission and writes K6DRK testing. West Marin. K6DRK. unprompted. What it is not is consistent: the same operator saying the same words a minute later came back as K6DRK testing West Marin K6DRK, no period anywhere, and in a log read as a column of one-line entries that reads as a transmission cut off mid-word. close_sentence closes the last sentence and leaves everything else alone, replacing a dangling comma rather than writing over it, and treating an ellipsis as the real ending it is. It runs last of all — after the filters have judged the entry real and after correct_callsigns has spelled it — because it is cosmetic and must have no vote in either.

Deliberately not a pause-detection rule. The clip boundary already is one (GAP_SECONDS), whisper is already using the pauses inside a clip, and a threshold on gap length would punctuate straight through a callsign — phonetics are delivered with a beat between each word ("Kilo … Six … Delta"), so the one part of the log that is not sentences is exactly where such a rule would insert sentence structure.

Priming whisper with that vocabulary is implemented and switched off. An initial prompt (--prompt, plus --carry-initial-prompt because a clip can run past one 30-second window) makes the model likelier to emit those exact words, which is both the point and the danger. The worst failure this device has is confident text invented from static, and "KM6AOW mobile" off a hiss burst passes every filter here, because it is a short, unrepetitive, entirely reasonable sentence. It is a per-channel switch that is off unless explicitly enabled, and there is a measurement that decides it: compare-models.py --compare prompt runs one model twice over the same audio, without the prompt and with it, over real traffic and over static captured unsquelched on purpose. Zero loggable lines from the noise on both arms clears it; one entry naming the roster is the veto. See Transcriber Diagnostics.

The LEVEL is the boundary between transmissions. The SDR this replaced emitted nothing at all while squelched, so a gap in the byte stream was the carrier dropping. A sound card has no opinion: it delivers silence at the same rate it delivers speech, forever, so that gap never comes and every over would run into the next.

AudioGate therefore opens above −55 dBFS and closes below −60 after a 1.2 s hang. It measures the audio band only — two cascaded one-pole high-passes at 200 Hz, then rms — because a squelch thump is loud and sub-audible: one measured clip carried 97.6% of its energy below 100 Hz against 0.1% in the band the repeater's Morse ID lives in, so a broadband level reads it as signal and opens on nothing.

Not an FFT, and not decimated. This runs ten times a second on a Pi with stdlib only, and decimating enough to make a pure-Python DFT affordable puts Nyquist below the 1500 Hz ID tone — the gate would sit deaf through every identification.

The thresholds are not guesses. Measured on 146.700 on 2026-08-29: squelch closed sits at −91 dBFS, flat within 2 dB, and speech peaks at −30. Any threshold in that 60 dB gap is unambiguous, which is the whole reason for moving the squelch into hardware.

1.2 s of hang, and the hang is not kept. A Morse word gap at ~11 wpm is about 770 ms, so a shorter hang cuts an identification into fragments; two overs on a busy net are about 1.5 s apart, so a longer one merges them. Quiet frames are buffered and flushed back if speech returns — a pause inside an over must survive — but discarded when the gate closes, or every clip would be 1.2 s longer than the transmission in it.

The gap is in the audio even though it is not in the byte stream, and carrier_gaps() measures it: frame level against the clip's own 95th percentile, so gain cannot move it. Checked against the courtesy beeps — located independently by band energy — it found 5 boundaries to 5 beeps, 4 to 4 and 5 to 5, and correctly rejected a spurious beep candidate a tone-only split would have cut on. It counts levels, not beeps, because a courtesy tone exists only on a repeater and these receivers are also pointed at simplex, where a carrier drops just the same. It is measured and recorded per clip and acts on nothing: GAP_SECONDS still decides where a capture ends.

A whole net says it should stay that way, and says why. 223 captures on 2026-08-30, 110 of them holding two or more overs, up to eight in one clip — merging is not an edge case, it is most of the long captures. Cutting the audio at every detected boundary produces 347 pieces, and 44 of them mid-capture carry too little sound to survive the empty-clip rule: about a second each with 0.44 s of speech in it, against the 0.80 s threshold. Those fragments ride safely inside a longer capture today and their words reach the log; split out and judged alone, they would be discarded. Splitting would trade 110 correct-but-merged entries for 194 correctly-attributed ones plus 44 holes.

And it cannot be done with beeps. Across those 110 captures an independent detector found five isolated courtesy beeps. The 1:1 agreement recorded above held on three captures where every over ended in silence-beep-silence; on a busy net the next station keys up inside the gap and there is no isolated tone left to find.

The version worth building splits the text, not the audio: whisper already reports when each phrase was said, --no-timestamps currently throws that away, and the boundary times are already known. One capture transcribed whole, then written out as one log entry per over. Whisper keeps full context, no fragment is ever judged alone, and nothing can be dropped — the words already exist.

A software audio-level squelch ran on top of that for a while, meant to find the edges of each over. It could not work, for a reason the measurement above makes obvious in hindsight: because the SDR's squelch emitted nothing while closed, the only audio it saw was speech, so the "noise floor" it computed was a speech level — 98, in the field. It then discarded anything quieter, which meant the opening syllables of every over, and cut the over in two at the first pause. A station saying "monitoring channel, K6DRK" was logged as "ring channel K6DRK." followed by a 1.2 s fragment whisper could make nothing of. It is gone; the gap needs no help.

Transcription runs on its own thread, and the audio never touches the card. Two separate reasons, both about what a Pi in a box somewhere can afford to lose:

The spool can live on a USB SSD; the boot disk cannot. /var/spool/transcriber moves to an SSD with transcriber/migrate-to-ssd.sh and transcriber/spool-to-ssd.sh, which is worth doing — the outbox and retained recordings are the only things here that write to the card repeatedly. Booting a Pi 4 from one is a different matter, and two findings cost most of a day:

rpi-eeprom-config --apply stages recovery.bin on the SD card, so a pending EEPROM update travels with the card into whatever Pi you put it in next. A spare Pi 4 in this fleet still carries BOOT_ORDER=0xf14 and will hang if the X9 is ever attached to it.

What must survive a reboot stays on the card under /var/spool/transcriber/<channel>: the outbox, so a transmission heard during an outage is not lost, and the measured gain and squelch, which are the only record of what this site was measured at and are never re-measured on their own. The backlog is bounded by both clip count and total bytes — a count alone bounds nothing when one clip can be 4 MB and /run is smaller than a hundred of them.

Calibration

There is nothing to calibrate on the device any more. Gain and squelch belonged to a tuner. A radio's squelch is a knob on its front panel, and its audio level is set once, by hand, against the meter on the channel manager — then stored in the sound card's own mixer state, not by this software.

Two settings must hold on the Pi, and both are stored with alsactl store:

amixer -c 2 sset "Auto Gain Control" off   # AGC destroys the silence the squelch provides
amixer -c 2 sset Mic 15                    # +3 dB, not the 35 (+23 dB) maximum

AGC off is not optional: it raises gain during silence, pulling the noise floor up into the 60 dB gap the whole approach depends on. Capture gain 15 rather than 35 because at maximum the radio's output overwhelms the card — half the volume knob's travel does nothing and all the useful adjustment is crammed into the bottom 20%.

Calibrate on open-squelch noise, and judge it on the audio band. Noise is the right reference because it is stationary — about a decibel of spread over a minute — and available on demand, where speech varies 20 dB inside a syllable and only arrives when somebody talks. Open the squelch, press Start meter, turn the volume until the bar reaches the target line at −27 dBFS, close the squelch again.

Never judge the level by the raw peak. The peak belongs to a squelch thump generated after the volume control, so no knob can move it. Levelling against it on 2026-08-29 set a target below the volume-off leakage floor, which meant the only way to reach it was to turn the audio off entirely — and ten seconds of test speech left no trace in the recording while several hours went into hunting a cable fault that did not exist.

Recalibrate whenever the radio moves or changes frequency. Nothing is stored: a level measured at one site says nothing about the next.

Transcriber Diagnostics

"Nothing is appearing in the log" has four different causes and they look identical from the web page. Ask these in order; an evening was lost to asking them out of order.

0. Is the channel running at all? Look at Status on /transcriber/ before anything else. The channel posts a heartbeat every 60 seconds for as long as it is alive, so the page can tell "listening, nobody is talking" from "not running" — which no amount of staring at the log will do, because both write nothing.

Status says Means
Listening (last transmission 4 minutes ago, worker 1.3) Healthy. Carry on to step 1.
Listening (nothing heard yet, …) The worker is fine. The band is quiet, the radio is off, or the level is wrong — step 5.
Not heard from for 12 minutes — the channel is not running. The unit is dead or crash-looping. journalctl -u transcriber@<id> and read the first line after each restart.
No heartbeat yet Either the same thing, or a worker older than 2026-08-29 that has no heartbeat to send. Check the version before assuming a fault.

This step exists because it was missing. On 2026-08-29 the channel crash-looped for 83 minutes across 162 restarts — the deployed worker was an older build that demanded a frequency the settings no longer carried, and it exited within a second every time. Every screen showed what it shows on a quiet band: nothing. What eventually noticed was somebody hearing traffic on a handheld and seeing it not appear.

Note what the heartbeat does not cover: the config poll runs from a timer that is alive whether or not the channel is, so a device answering ?status proves the Pi is up, not that it is listening. Those are different questions and only this one answers the second.

1. Is it capturing, or hearing nothing? The journal is the only place that distinguishes them, and it says so plainly:

journalctl -u 'transcriber@<channel>' -f
What you see What it means
logging (11.6s): … Working. The entry is on its way to the log.
discarded (12.5s): '' It captured audio and whisper found no speech in it. A squelch that is too low does this all day — see 3.
discarded (1.3s): '…' Too short to be words. A key-up or a squelch tail; correct to drop.
discarding 120s clip — open carrier? The carrier never dropped. A stuck transmitter, or no squelch at all.
N transmissions in the last 30 minutes The heartbeat. no transmissions on a frequency you can hear means the receiver is not opening — a quiet frequency and a deaf receiver are otherwise indistinguishable, which is the whole reason this line exists.
nothing at all Not running. Check systemctl status 'transcriber@*'.

2. Did the entry reach the server? If the journal says logging but the log has nothing, the entry is either queued or the display is at fault. The outbox is the answer:

ls /var/spool/transcriber/<channel>/outbox/ | wc -l

Empty means the server accepted it, and the problem is on the web side rather than the radio side. Entries accumulate there when the server is unreachable and flush in order when it returns.

3. What gain and squelch did it start with? The first line after a restart says, and they are the two numbers that decide what gets recorded:

squelch 30 — set in the manager for this channel               ← an override you typed
gain 16.6 dB, squelch 20 — measured for this site 40.2 hours ago
gain 30 dB, squelch 25 — the built-in defaults. This channel has never been calibrated…

Why the SDR was abandoned, kept because the failure is instructive. Automatic gain and an RF squelch cannot both work: the squelch compared received power against a threshold, and AGC changed what that power meant, winding the gain up on a quiet band until noise crossed whatever level was set. Measured on an idle frequency with nothing on the air — squelch 40 open 92% of the time, 50 open 25%, 60 open 22%, and that same 50 reading 0% ten minutes earlier. Not a threshold slightly wrong: a threshold that meant something different every few minutes.

The symptom was a channel recording its own noise floor: hours of long clips, nearly all transcribing to nothing, on a frequency whose real duty cycle is a fraction of a percent. Six hours of it produced 154 minutes of "audio" from a band that was almost entirely silent, and whisper was run twice over every second of it.

A radio's squelch has none of that problem. It gates on the carrier, in hardware, and the gap between closed and open is 60 dB.

Which value is a question about the site, so it is measured there — see Calibration below. 30 dB is what a channel uses until somebody measures it, and 30 dB was measured at one location.

4. Is something else holding the sound card? Only one process can open a capture device, and the failure is silent from the other side: arecord simply gets nothing. This is the single most common way the new path breaks, because the calibration meter, the bench tool and the channel itself all want the same card.

arecord exited (1): audio open error: Device or resource busy

fuser -v /dev/snd/pcmC2D0c names the holder. Stop that, not this.

The channel also restarts itself after an hour of total silence, so a card that opens and then stops delivering becomes visible within the hour rather than whenever somebody asks why the log is empty.

5. Is audio reaching the Pi at all? Record fifteen seconds and look at the level, which distinguishes the three things that look alike from the log: a quiet channel, a radio turned down, and a dead cable.

arecord -D plughw:2,0 -f S16_LE -r 16000 -c 1 -d 15 /tmp/t.wav

With the squelch closed this should sit near −90 dBFS. With it open it should read about −27. A flat trace within a decibel or two at any level is a dead path — real audio swings 20 dB or more, and that one test separates "working" from "broken" faster than anything else here.

Comparing two configurations over the same audio. compare-models.py captures each transmission once and runs two arms over that same file. The arms differ by the model, or by whether whisper is primed with the event vocabulary:

sudo /opt/transcriber/bin/compare-models.py --channel <id> --clips 10 --minutes 20
sudo /opt/transcriber/bin/compare-models.py --channel <id> --compare prompt

One capture, not two channels. Two receivers hear slightly different things, so any difference in the text would be confounded with a difference in what arrived — which is the one thing the comparison is supposed to hold constant. It borrows the worker's own capture path (same squelch, same gap segmentation, same filters), posts nothing to the log, marks which lines the filters would have dropped, and reports each arm's speed against real time. Above 1.0x an arm cannot keep up with a busy net.

--compare prompt builds its prompt with the worker's own Vocabulary, from the same /etc/transcriber/channels.json the device reads, and prints it — a measurement of a lookalike would be worth nothing, and a prompt naming last month's event would otherwise look like a result. It refuses to run against an event with no vocabulary rather than reporting the "identical on 6 of 6" that two identical arms would produce.

Each transmission shows both arms' text and what correct_callsigns would make of it, because the worker corrects callsigns after transcription: a difference the correction pass closes by itself was bought for nothing, and the summary counts the two separately. Only the entries still different after correction are what a prompt actually buys.

Then it captures static on purpose, and that pass is the point of the exercise. The receiver no longer records silence — the gain is pinned and the squelch gates properly, so a quiet frequency yields no clips at all, which is correct and removes the very thing this test needs. So the second pass opens the gate itself (-l 0, the absence of a threshold rather than a low one), on the channel's own frequency, in clips the length of an over, and asks of each arm how many clips of nothing produced something the log would have accepted. It reports that separately, with the text.

Zero on both arms is what clears a prompt to ship. Any entry naming a callsign, tactical call or place name from the vocabulary is the finding and the veto — checked after correct_callsigns has run, since "kilo six delta romeo kilo" off a hiss burst is K6DRK named in the log. An invented line reading "K6DRK at Cardiac" is far worse than a mangled callsign: it is plausible, it names a real person and a real place, and nobody has any reason to doubt it.

--static-clips 0 skips the static pass, --clips 0 skips the traffic pass. It stops the channel while it runs, because there is one capture device per channel, and starts it again however it exits, including on Ctrl-C — the first Ctrl-C ends the wait for traffic and goes on to the static pass, the second ends the run.

A diagnostic that owns the sound card says so. There is one capture device per channel, and several things want it: the nightly self-noise sweep, compare-models.py, sdr-usb-test on the gates. Meanwhile each fleet has something that puts a stopped receiver back within a minute — the iGate's watchdog from cron, the Transcriber's 60-second config poll. Left to themselves the two fight, and the symptom is not a crash but a measurement quietly taken against a contended device.

So a tool that stops a receiver leaves a flag while it works, and the supervisors stand down when they see one:

Flag Set by Honoured by
/tmp/sdr-usb-test.pause sdr-usb-test, sdr-selftest.sh (iGate) igate-watchdog.sh
/tmp/transcriber-bench.pause compare-models.py auto-update.sh

Both are ignored once stale — eight hours for the Transcriber's, and the iGates clear /tmp on their nightly reboot — so a tool that dies without cleaning up cannot keep a receiver off the air indefinitely. Anything new that takes the card should set the one its fleet already watches rather than inventing a third.

Key files on a Transcriber Pi:

File Purpose
/etc/transcriber/channels.json This device's channels, collected from the manager
/home/pi/.transcriber-token Its config token — how it identifies itself
/var/spool/transcriber/<channel>/outbox/ Entries the server has not accepted yet
/var/spool/transcriber/<channel>/recordings/ Retained clips and manifest.jsonl, while a recording window is open
/run/transcriber/<channel>/ Clips in flight, on tmpfs — and arecord.err, which is where "Device or resource busy" goes
/var/log/transcriber/update.log What the nightly and 60-second updates did

SSH: ssh pi@<ip> · Password: guacamole

User Interfaces

Six browser-based interfaces run on marsaprs.org. All are served by Apache on the server Pi, accessible at https://marsaprs.org/<path>.

Interface URL Access Purpose
Map / Public Live tracker positions on an interactive Leaflet map
Map Admin /admin/ User account Event configuration, tracker list, course and aid station management
Analyzer /analyzer/ User account Beacon recording, playback, and analysis for the current event
NetBird Monitor /netbird/ User account Real-time health status of all Pi devices
NetBird Admin /netbird/admin.php User account Add/remove devices, enable/disable, SSH terminal
WiFi Manager /wifi/ User account Edit the shared WiFi credential list distributed to all Pis
Transcriber /transcriber/ User account The receiver's per-event settings, its Status, and the level meter
Tickets /tickets/admin.php User account Bug report and suggestion ticket management

Map — Shows tracker positions updated every 5 seconds. Sidebar lists trackers (with elapsed time and breadcrumb history), courses, aid stations, iGates, and map backgrounds. Hovering a tracker or breadcrumb dot shows its APRS path (iGates/digipeaters the packet traveled). Breadcrumbs are filtered: consecutive duplicate positions and positions within 100 feet of the previous breadcrumb are suppressed. The breadcrumb trail shows up to breadcrumb_count positions as dots on a dashed line with directional arrows; the trail updates automatically as the selected tracker moves. iGate tooltips include an optional callsign; aid stations show their name only. A scale bar in the lower-right corner toggles between miles/feet and kilometers/meters when clicked. Kiosk mode removes controls for unattended display use.

Map Admin — Requires admin.view or admin.edit. Manages all event configuration: tracker callsigns and IDs, GPX/KML/GeoJSON course overlays, aid station and iGate locations (with optional APRS callsign), map default view, and background tile layers. Supports multiple named events; switching events is instant. Users with only admin.view see all data read-only. See Appendix — Server — Admin Interface for full details.

Analyzer — Requires analyzer.view. A Flask/gunicorn web app served at /analyzer/ via Apache mod_proxy. Runs analyzer-daemon to record APRS beacons from APRS-IS into a local SQLite database for the current event. Displays recorded beacons on an interactive Leaflet map with controls for filtering by tracker and iGate, toggling radio vs. cellular beacons and course overlays, adjusting the auto-refresh rate (30 s–5 min per client), and scrubbing a time-range slider. Daemon start/stop and data erasure require analyzer.admin.

NetBird Monitor — Requires netbird.view. Polls all registered Pi devices every 60 seconds (default; adjustable) over the NetBird VPN and shows online/offline/enabling/disabled status. Includes an SSH terminal for online devices. Poll/refresh sliders and the Admin button are shown only to users with netbird.admin. See Supporting Systems — NetBird Status Monitor for full details.

NetBird Admin — Requires netbird.admin. Manages the device list in addresses.yaml: add, edit, delete, enable, and disable devices. Access to the SSH terminal for any online device.

WiFi Manager — Requires wifi.admin to edit; accessible read-only to users with netbird.view. Edits wifi.yaml, the master list of WiFi networks distributed to all Pis nightly. Auto-saves on every change; no manual save step.

Tickets — Requires tickets.manage. View and manage bug reports and suggestions submitted via the in-app ticket form.


Authentication

All protected browser interfaces use a shared named-account system. A single sign-in at /auth/login.php establishes a session that is recognized by every tool — Admin, Analyzer, NetBird, WiFi, and Tickets — without re-entering credentials.

User Accounts

Accounts are stored in an SQLite database at /var/lib/marsaprs/users.db (outside the web root, owned by www-data). Each account holds a username, display name, and bcrypt password hash, plus an active flag. Inactive accounts cannot log in.

After a successful login the server inserts a row into the sessions table with a 64-character hex token and sends it to the browser as the marsaprs_session cookie (httponly, secure, SameSite=Lax, 24-hour TTL). Both PHP pages and the Python Flask analyzer validate the same cookie against the same database.

Permissions

Access is controlled by flat permission strings. Each user is granted exactly the permissions they need; there are no implicit roles or inheritance.

Permission Guards
admin.view /admin/ — read-only view of all event config, trackers, aid stations, iGates
admin.edit /admin/ — full edit and save
admin.edit_trackers /admin/ — edit only the Trackers section (add/remove/rename mobile trackers, set display ID and ham callsign, tracker mode, Hide toggle, beacon reset) without full admin.edit; requires admin.view
admin.set_default Save as Default Event action
admin.delete_event Delete event action
analyzer.view /analyzer/ — event map and beacon data
analyzer.admin Daemon start/stop; Erase All Data
netbird.view /netbird/ — status page; read-only WiFi page
netbird.admin /netbird/admin.php; poll/refresh sliders; full WiFi edit
wifi.admin /wifi/ — edit WiFi credentials
tickets.manage /tickets/admin.php — ticket list and management
messages.manage Messaging admin: Delete All Messages, and Manage operators (disconnect a stuck/idle operator to free their name). (Renamed from messages.delete_all.)
users.manage /auth/users.php — create/edit/delete accounts and permissions

The authoritative list is KNOWN_PERMISSIONS in server/www/auth/users.php; a permission missing from it cannot be granted in the UI. Pages outside the auth tree guard themselves with require_once __DIR__.'/auth/auth.php' then has_permission(...)map/index.php does this lazily (msgHasAuthPermission()) so the public map never opens users.db for anonymous visitors.

Login Flow

Every protected page passes unauthenticated requests through require_permission($perm), which redirects to /auth/login.php?next=<original-url>. After a successful login the browser is sent back to the originally requested page. Logging out (/auth/logout.php) deletes the session row and clears the cookie.

Users with admin.view but not admin.edit see the Admin page in read-only mode: all data is displayed but edit controls, save buttons, and import/export actions are hidden. Granting admin.edit_trackers alongside admin.view unlocks editing of just the Trackers section (the controls listed above) while the rest of the page stays read-only. The same permission-aware rendering applies to the NetBird status page (sliders and Admin button hidden for netbird.view-only users) and the WiFi page (edit controls and drag-to-reorder hidden).

User Management

Users with users.manage access /auth/users.php (linked from the Admin page header as Users). From there they can create accounts, reset passwords, toggle active status, and assign or revoke individual permissions.

Key Files

File Location Purpose
auth.php /var/www/html/auth/ Shared PHP library: current_user(), has_permission(), require_permission(), create_session(), destroy_session()
login.php /var/www/html/auth/ Login form (GET) and credential validator (POST)
logout.php /var/www/html/auth/ Destroys session, redirects to login
users.php /var/www/html/auth/ User management UI (requires users.manage)
init_db.php /var/www/html/auth/ Idempotent DB bootstrap; creates first admin account when no users exist
auth_db.py /home/pi/analyzer/src/ Python mirror of auth.php; validates marsaprs_session cookie in Flask
users.db /var/lib/marsaprs/ SQLite database: users, permissions, sessions tables

Event Lock Password

The event-level lock feature (🔒 in the Admin page) still uses admin/password.txt as a separate credential. This is distinct from user-account authentication — it controls whether a specific event's YAML can be overwritten, not who can log in. All other admin actions are gated by the user-account permission system.


Messaging System

Messaging lets web operators and mobile tracker participants exchange text messages in real time — one-to-one, in named groups, or broadcast to all mobiles. It is enabled by setting messaging_password in the event's event.yaml (via the Admin page). As of 1.21.0 the store is a single server-side SQLite database and both clients present a familiar chat UI (conversation list + thread + always-visible composer) with delivery/read receipts and optional photo attachments. This replaced the former per-event messages.json log and per-tracker pending_msgs queues.

Data Storage — messages.db (SQLite)

All messaging state lives in one SQLite database, /var/lib/marsaprs/messages.db (outside the deploy tree, like users.db and the analyzer's aprs.db, so deployments never wipe it). It is event-scoped — every row carries an event column. Helper map/messaging_db.php owns the schema and all access; map/messaging.php is the JSON API layer.

Table Purpose
participants One row per addressable party per event — mobiles (keyed by callsign) and operators (keyed by unique name). Holds display_name, short_id, token, last_seen, and last-known lat/lon/pos_ts.
conversations A direct, group, broadcast, entity, entity_multi, or log thread, with a member_hash so a given set of participants maps to exactly one conversation.
conversation_members Membership join between conversations and participants. Empty for broadcast conversations — see below.
messages The messages: monotonic id (the wire id for since_id polling), event, conversation_id, sender_id, ts, text, text_ts (when a transcription was filled in on a row that already existed — see below), sender lat/lon/pos_ts, broadcast, photo columns (attachment, attach_w, attach_h), and radio-audio columns (audio, audio_secs).
deliveries Per-recipient row for each message with delivered_ts / read_ts — this is the inbox, the unread count, and the delivery/read receipts. Replaces the old pending_msgs queue.

Message IDs are monotonic. Each client polls with a since_id watermark, so ids must never go backwards; the DB assigns them from an always-increasing sequence and Delete All Messages does not reset it.

An id cursor cannot say "this row changed", and one kind of row does. A Transcriber posts the recording the instant an over ends — that POST creates the entry — and returns with the words a few seconds later, onto the same row. A client polling on ids can fetch that entry inside the window, take the audio, advance past the id and never be offered the transcription: on 2026-08-29 four consecutive overs reached the phones as recordings with no words while the server held both halves correctly.

So there is a second cursor. text_ts is stamped by setMessageText and nowhere else, and ?monitor and ?thread accept since_text_ts: pass it and the answer also carries already-seen rows whose words landed after the id cursor swept by.

Opt-in, and that is the design rather than caution. v1.25.4's MonitorService appends to its list without deduping and queues clips with no message id, so handing it a row it already holds would replay the recording — worse than the missing text. A client that does not send since_text_ts gets byte-identical answers to before it existed, which is what let the server ship days ahead of the phones.

One person, several phones: entities. A volunteer may carry two devices (a phone and a spare, iPhone and Android). Each is a separate mobile session with its own callsign, so without help they appear as two recipients and two threads. Everyone sharing both display_id and name is treated as one entity:

Case Picker shows Addressed as
CRD Stanton on two phones one row, "2 devices" ent:CRD\|Stanton → both
LKL Dirck + LKL Jerry two rows plus "LKL (multiple)" mult:LKL → everyone at LKL

Both pickers sort alphabetically by display_id then name, with each (multiple) row sorted to the head of its own ID group so it sits directly above the people it covers rather than trailing the list. Each (multiple) row reports how many entities it addresses ("5 people at CM"), and a multi-device row reports its device count ("2 devices"). The web builds this in _pickerOptions(); the app sorts in _RecipientPicker using MsgParticipant.groupId/isMultiple, with the counts carried by the devices field from ?messaging=participants.

display_id is the grouping key by design — it is operator-editable in the Admin UI (?setdisplayid) and is routinely edited to make this merging possible. The underlying callsign never changes and remains the APRS identity.

Entity threads are keyed to the entity, not to the device set. resolveEntityConversation() builds member_hash from <operatorId>|ent:<display_id>\x1f<name>, so a phone going offline, coming back, or a third being added never forks the conversation — which hashing the participant set would do. Delivery is re-resolved from the live tracker feed on every send, including replies, so a device whose display_id was edited away stops receiving even though it stays a historical member. Threads are per-operator: two operators messaging the same entity get their own conversations, exactly as with a direct thread.

Kinds are distinct: entity is one person (receipts collapse — any phone acknowledging counts as delivered/read), entity_multi is several people sharing a station (keeps "N of M").

Merging folds prior history in, reversibly. When an entity thread is resolved, migrateThreadsIntoEntity() moves the operator's earlier one-to-one messages with those devices into it — otherwise merging two phones leaves a second, stale "CRD Stanton" in the conversation list. Two safeguards:

Emptied threads are hidden, not deleted (conversationsFor() skips conversations with no messages). Deleting them orphaned the history on undo — the messages returned to a conversation_id whose row no longer existed.

The entity thread is chosen, not merged into afterwards. Migration only ran when an operator sent via an ent: picker row, so any other path that addressed a device by callsign — notably right-clicking a tracker, which composes recipients:[callsign] — resolved a fresh direct thread beside the entity one and undid the tidying. Both render from the same display_id and name, so the operator saw the same person listed twice with the history split; they cannot converge on their own because the hashes differ (111|ent:-LOGDoug vs 77,111). entityConversationForDevice() now looks the person's thread up at send time when an operator addresses a single device. Restricted to kind entity, never entity_multi — a (multiple) thread is a station's whole group, and routing one person's message into it would put a private reply in front of everyone there.

The event log is a conversation with no recipients. One log thread per event (member_hash = 'log:*'), holding entries that were written rather than sent — times, arrivals, decisions. insertMessage() with an empty recipient list writes no deliveries, so nothing is queued for anyone to poll, no client announces it, and no receipt can come back; everything else (storage, threading, history, CSV export) it gets for free by being an ordinary message. Memberless like the broadcast thread, because it belongs to the event rather than to whoever made the first entry — which also settles access without a rule of its own, since the thread handler already lets an operator open anything and refuses a non-member everything. The one place that could still leak it is the conversation list, so conversationsFor() takes an $includeLog flag the caller sets only for operators. to_label is 'Log' in both thread() and history(), and entries are attributed to their author — the inverse of the rule for ordinary traffic, because the log is shared and is read later by someone reconstructing events.

The mobile app needs no change for any of this: ?messaging=participants returns entity-grouped rows keyed ent:/mult:, and the app already renders short_id + name and sends p.key back. That endpoint also now filters to addressable participants only (mobiles with a live session in the last 24 h, operators seen in the last 24 h); it previously returned the entire never-pruned participants table, which is what filled the app's picker with one volunteer repeated across four old sessions.

Broadcast access is decided by kind, not membership. Direct and group conversations are keyed by member_hash — the member set is the identity, so whoever finds one is already in it. The broadcast conversation is different: its hash is the constant '*', so a single row is shared by the whole event, and it therefore records no members at all. Access goes through canAccessConversation(), which admits any participant in the event; isConversationMember() remains a literal membership test for callers that need one (such as the mobile reply-routing path, which deliberately skips broadcasts). conversationsFor() likewise unions the broadcast thread in for every participant.

This matters because membership and delivery must not disagree. conversationRecipients() fans a broadcast out to every participant, so everyone gets a deliveries row and their client rings the alert tone. If the thread were membership-gated, those recipients would be refused when they tried to open it — the message would arrive audibly and be unreadable. That was a real failure during Dirt Fondo 2026: findOrCreateConversation() wrote members only on the creation path, so the event's one broadcast conversation listed only the operator who happened to send the first announcement. Every later broadcast reused that row, mobiles got 403 Not a member of this conversation, and on the sender's screen the thread rendered as a direct chat with that first operator instead of All Trackers.

Photos. An attached image is stored as a file under /var/lib/marsaprs/photos/<event>/ (private, owned by www-data), with the message row referencing it and attach_w/attach_h recording its dimensions. Photos are served only through the auth-gated ?messaging=photo&id=&token= endpoint (never a public path), validated with getimagesize and capped at 12 MB. The server has no image library, so the mobile client downscales before upload. Deleting an event's messages (or Delete All Messages) removes the event's photo directory too, and an event backup/export bundles both the messages and the photos.

API (?messaging=…map/messaging.php)

Action Purpose
subscribe / identify Operator (name + password → token) / mobile (token → participant). Operators must choose a unique, non-empty name — there is no Operator default.
participants Addressable list for the event (mobiles + operators, with short_id, display_name, online state) — powers the any-to-any recipient pickers. Mobiles are offered for 24 h; operators only while actually present (MSG_ONLINE_SECONDS), because a stale operator row is a name nobody is behind.
send {token, recipients:[keys] | 'all', text, conversation_id?}, optionally multipart with a photo. Resolves/creates the conversation and writes the message + deliveries.
poll {token, since_id} — new messages addressed to me plus delivery/read updates; incremental.
thread The running exchange for one conversation (members only).
history The full event log (View All / admin) — operators only.
log {token, text} — append an entry to the event log. Operators only; writes a message with no recipients, so no deliveries rows exist and nothing is queued, announced, or acknowledged.
monitor {token, since_id}read-only feed of the event's traffic for a phone that opted in. Creates no deliveries rows (see below). Bounded by age and count, and reports how many it skipped.
log_audio Transcriber: post the recording before the transcription exists, returning an entry_id that the later log call completes.
read Mark delivered messages read (read receipts).
photo Stream an attachment (conversation members or operators only).
flush Per-event wipe, gated by messages.manage.

Operator presence is measured from poll traffic, which the BROWSER controls. That window was 90 seconds and it flapped. The console polls every 5 s, but a browser throttles timers in a hidden tab — Chrome clamps setInterval to roughly once a minute — and net control's console is a background tab most of the time. Sampled against a live idle console on 2026-08-30, last_seen ran 38 s, 47 s, 56 s, 4 s, 13 s: refreshed about once a minute against a 90-second window, so thirty seconds of margin, and anything slower spent it. An operator sitting right there vanished from every phone's New Message picker with nothing on either device to say why. It is 180 s now, which still separates a live console from rows whose last_seen is never — the thing the gate exists for. Being generous costs little and in one direction only: a message to an operator who stepped away queues and is delivered when they return.

The monitor feed — read-only by construction

A phone following the whole event cannot use poll: that is an inner join on deliveries, and a message not addressed to you has no row there. The obvious implementation — give the monitoring device delivery rows and reuse pollFor() — is the one thing that must never ship.

receiptsForSender() counts all delivery rows for a message. A monitoring phone would therefore turn every 1:1 into "Delivered to 1 of 2" for the sender, and its pending state would never clear — silently, event-wide, for everybody. The unread subquery in conversationsFor() and recentInboundConversation() would likewise start pointing the monitor at strangers' threads.

So monitor() is a plain SELECT over messages and writes nothing at all. The regression test that matters is not "does the monitor see the message" but "does a 1:1 still report total = 1 while a monitor is running". markRead()/markDelivered() on ids with no delivery row are already silent no-ops, so a monitoring client is safe by construction on the way back too.

Every message carries addressed: whether this monitor also has a delivery row for it. The feed is the whole event, so it returns the messages sent to you alongside everyone else's — and the phone announces addressed traffic and monitored traffic on two independent paths, so those messages were read aloud twice, a few seconds apart. Worse, the monitor path is the one speech path that never asked the wrist, so it also talked over a watch that was already announcing the same message.

The tempting fix is to exclude those rows here, and it is wrong: this feed is also the event's traffic log, and dropping the messages sent to you would leave holes in the one view whose entire purpose is completeness. Tagging lets the client show everything and announce once — _handleMonitoredBatch skips anything tagged, and _handleInboundMessage keeps sole ownership of it, including the choice between the wrist and the phone and the marking-read that follows.

It has to be a server tag rather than a client-side guess. The client can see which messages reached it on an addressed path, but the two polls run at different intervals, so a monitor batch can arrive first and would announce before there was anything to claim. WatchBridge.addressedHere() remains as a fallback for a server that has not been updated yet — right whenever the addressed path won the race, which is usual but not guaranteed.

The same feed is also the reason _handleMonitoredBatch filters out your own transmissions by fromId: a sender gets no delivery row for their own message, so every other feed excludes them for free and this one does not.

Catch-up is bounded (MONITOR_MAX_AGE 5 minutes, MONITOR_MAX_RESULTS 50) and reports skipped rather than truncating quietly — the phone shows "42 monitored messages skipped" instead of leaving a silent hole where half an hour of the net used to be. Directed messages are deliberately not bounded: pollFor() has no age or count limit, so a message addressed to you is never dropped however long you were away.

rehomeSession also had to learn about mobiles. It was applied only to operators and transcribers, so a phone restoring a persisted token across an event change stayed homed in the old event. pollFor has no event predicate, which is why that was invisible — but an event-scoped feed would have served the new event's traffic to a participant row in the old one.

Radio audio — public, static, edge-cached

Radio clips are the one attachment served outside PHP: they land under the web root at an unguessable <mid>-<12 hex> name and Apache serves them with Cache-Control: public, immutable, max-age=31536000. Fifty hands-free phones each fetching every clip of a busy net is on the order of ten thousand mod_php invocations an hour, arriving in bursts because every client polls on a similar cadence. There is no multicast over HTTP; edge caching is the substitute, and it means the Pi serves each clip roughly once however many phones want it.

This is defensible only because amateur transmissions are public by law. Photos stay exactly as they are — outside the web root, PHP-gated, Cache-Control: private — because a photo attached to somebody's message is not public, and the distinction is the whole justification. The URL is obtainable only from the authenticated feed, so it is a capability URL rather than an open directory.

Clips are pruned by age (AUDIO_MAX_AGE, 6 hours) and removed with the event by flushEvent(), which already did the same for photos.

Backward compatibility. The legacy mobile endpoints (?mobile=message, mobile poll, web_recipients) are a thin shim over the same messages.db, so an older app keeps working unchanged alongside the new chat clients — old and new interoperate through one database.

Web Operator UI (chat panel)

The Messaging button subscribes (name + password → token, remembered in localStorage). Messaging then lives in a persistent chat panel docked right of the map: a conversation list with unread badges, the selected thread, and an always-visible composer that incoming messages never cover. The 5-second poll drives live updates into the open thread, the list, and the unread counts without a reload. New message searches mobiles + operators (multi-select → a group; All Trackers → broadcast). Sent messages show Delivered ✓ / Read ✓✓ (or N of M in a group). A Read aloud toggle speaks arriving messages (Web Speech API); when off, a volume-controlled tone plays. A microphone dictates into the composer. View all opens the whole-event feed — searchable, and clicking a message opens its thread; operators holding messages.manage also get Export CSV and Delete All Messages (both re-checked server-side). The panel's settings menu also offers Manage operators (same permission) — a list of every operator with their connected/idle state and a Disconnect button that frees a stuck name and signs that session out. A name is only "in use" while an operator was seen within the last 90 s with a live token; both subscribe and rename use that same test, so a departed operator's name auto-frees.

Conversation list housekeeping. All Trackers and the Event Log are pinned at the head of the list and always present — the two destinations that always exist should not have to be composed to, nor scroll away under ordinary traffic. Below them, threads whose other end has not been seen for 24 hours are hidden, keeping anything unread or currently open. Staleness is read from the tracker feed's lastUpdate, not participants.last_seen: upsertParticipant() writes last_seen on every write and it is written in bulk by paths that say nothing about activity (_msg_ensure_all_mobiles() touches every tracker on each broadcast), so a station gone for weeks looked freshly seen the moment somebody else broadcast. The recipient picker had always used the tracker feed, which is exactly why it was right about who was reachable while the conversation list was not — _msg_mark_stale() in messaging.php now puts both on the same clock. Each message also carries a copy button that yields the message text alone, with an execCommand fallback for the non-secure contexts (plain-HTTP LAN, NetBird address) where navigator.clipboard is unavailable.

Two-screen operation (?messages). The same document rendered as a messaging-only window for a second monitor — the map's chrome hidden and the panel promoted to fill the window. Not a separate page: the messaging JS is interleaved through index.php and everything it touches has to stay in scope. Opened from the panel menu via a named window.open, so repeat clicks focus the existing window.

The operator session already spans windows — it lives in localStorage and the restore at script load hands a second window the same token — so the second window must never subscribe: that would mint a fresh token, invalidate the map window's, and also collide with the 90-second name-in-use check. A ?messages window opened directly, with no session to inherit, says how to open it properly rather than offering a modal that would do the damage.

Both windows poll, so exactly one announces. The messages window owns audio while open via a speaker lease in localStorage, renewed on each poll rather than by a heartbeat timer: background tabs have their timers throttled (to 1/s, and 1/min once hidden a while), so a merely covered messages window stops heartbeating while still running, and a timer-based liveness check reads that as death — measured against a real browser, the map window wrongly reclaimed audio and both would have announced. Renewing from the poll ties the lease to the activity that produces an announcement, so the two cannot disagree: a window that stopped polling stopped announcing, and reclaiming is then correct. The map window evaluates it lazily at announce time and needs no timer of its own. A BroadcastChannel carries the rest — a message's location pin drives _showMsgLocation() on the map window, and a tracker right-click on the map composes in the messages window.

Auto-subscribe for Display Pi operators: when ?autologin&operator=<name> embeds the messaging password into the page (via PHP session → in-page script), the panel subscribes silently on load. The token is held only in memory, so removing line 1 of ~/autologin.txt and rebooting cleanly unsubscribes.

Mobile Participant UI (chat screen)

The app's Message (💬) button opens a chat screen mirroring the web panel: a Conversations list, threads, and a composer, with New message → Start conversation / Start group over the participant list (with Online/Offline presence). It sends text and photos (Take a photo / Choose from library), shows Delivered ✓ / Read ✓✓ receipts, and can Read arriving messages aloud. Background arrivals raise a notification and the message is read out; tapping the notification opens the conversation. The app continues to satisfy the older ?mobile= contract, so mixed-version fleets interoperate.

Two additions beyond the web panel, both for following an event rather than taking part in one — see Monitoring the whole event under Mobile Apps:

Radio entries carry a Play button when the channel sent its recording. Tapping it queues the clip like everything else audible — it will not start on top of a message being read aloud, and the Stop control can see it. It bypasses the five-minute staleness rule, because that rule exists to stop a backlog playing itself and has no business refusing a button somebody just pressed.


Analyzer

The Analyzer is a separate Flask web application served at /analyzer/ that records APRS beacons into a local SQLite database during an event and provides an interactive playback and analysis map. It is the only interface that preserves a historical record of all positions received — the main map only retains the most recent breadcrumb_count breadcrumbs per tracker (10–100). The Analyzer is intended for post-event analysis, coverage review, and real-time monitoring of beacon reception quality.

Analyzer Architecture

APRS-IS (noam.aprs2.net:14580)
      │ TCP  (filtered to tracked callsigns)
      ▼
aprs_daemon.py  ──writes──▶  aprs.db  (SQLite)
                                   │
                             flask_app.py  ◀── browser polls on auto-refresh timer
                                   │
                             gunicorn  (127.0.0.1:5001, 2 workers)
                                   │
                          Apache mod_proxy  (/analyzer/ → 5001)

Two systemd services work together:

Service Unit file Purpose
analyzer analyzer.service gunicorn serving flask_app:app
analyzer-daemon analyzer-daemon.service aprs_daemon.py recording beacons to SQLite

analyzer-daemon is controlled from the Analyzer UI — it does not start automatically at boot. The analyzer (web app) service starts at boot and is always available even when no recording is taking place.

flask_app.py uses ProxyFix(app.wsgi_app, x_prefix=1) so that the /analyzer/ path prefix is correctly stripped before routing.

Analyzer Authentication

The Analyzer uses the shared user-account system described in Authentication. Access is gated on two permissions:

Permission Required for
analyzer.view All pages and API endpoints
analyzer.admin POST /api/daemon (start/stop); POST /api/flush (erase data)

The marsaprs_session cookie is validated by auth_db.py against /var/lib/marsaprs/users.db on every request. Unauthenticated requests are redirected to /auth/login.php. The Collect Data checkbox is disabled in the UI for users who lack analyzer.admin; no password modal is shown.

Beacon Recording

aprs_daemon.py connects to noam.aprs2.net:14580 with an APRS-IS filter string built from all callsigns in the current event's configuration (trackers from config.yaml, mobile participants from mobile_trackers.json). It is managed by analyzer-daemon.service and controlled from the Analyzer UI by users with the admin password.

Which event beacons are recorded against. Always the current event — the one named by event: in config.yaml (the symlink to events/<name>/event.yaml). The event roster lives in the Admin UI, which does not write to aprs.db, so an event that has never been recorded has no row in the events table yet. Both recorders (and the Analyzer page itself) create that row on demand via ensure_event(), so creating an event in the Admin UI is all the setup required — there is no separate database step. events.start_time records when the event was first seen and is informational only; end_time is unused.

There is no recording time window. Recording runs for exactly as long as the services run, and the Record control in the Analyzer UI is the only start/stop. Earlier versions gated recording on events.start_time/end_time, but nothing in event.yaml ever supplied those values — an event with no database row silently fell back to a ten-minute window and wrote its beacons with a NULL event_id, where no query could see them. Both recorders now refuse to start rather than record against no event.

What is recorded: For each received packet that matches a tracked callsign: callsign, latitude, longitude, Unix timestamp, receiving station (iGate), and the full APRS path string. Stored in the beacons table of aprs.db (SQLite), keyed to the current event by event_id.

Deduplication: Consecutive beacons for the same callsign at the same position within a short interval are collapsed during the get_ordered_deduplicated_beacons() query so they don't clutter the playback trail. The query dedupes per callsign and then returns the list sorted globally by time, so the playback range maps its list-index sliders directly to a chronological window (see Map & Controls).

Why position_tolerance is 0.0001 (~11 m). When one of our own iGates is also the first gate to reach APRS-IS, both recorders log the same beacon, and the two feeds report its position differently. The public APRS-IS feed carries uncompressed DDMM.mm positions, quantized to 1/100 minute (1.6667e-4°), while relay_daemon.py reads full precision from the relay capture:

public APRS-IS:  37.97983333333333, -122.5775      (1/100-minute grid)
relay capture:   37.979808,         -122.577478    (full precision)

Rounding to nearest bounds the disagreement at half a quantization step — 8.333e-5° — and measurement across 63 real pairs found a maximum of exactly 8.33e-5, matching the bound to every digit. A tolerance of 0.0001 therefore provably collapses every such pair (the former 0.00001 caught 2% of them, leaving the map drawing two markers a few metres apart). Widening is safe because receiver is part of the comparison, so beacons heard by different gates are never merged — which is the distinction the per-iGate recording exists to capture. Measured against real event traffic, the wider tolerance removed only cross-feed duplicates and thinned no legitimate trail: the morning's public-only rows deduped to 49 at both settings, with per-tracker counts identical.

A timestamp-based dedup was considered and rejected. The two feeds do not agree on time either — relay_daemon.py stores int(time.time()) from the VPS at gating time (truncated to whole seconds), while aprs_daemon.py stores time.time() on the server Pi at APRS-IS receipt (position packets carry no timestamp field, so the fallback always applies). The difference is always positive and small (median +0.59 s, max +1.12 s over 62 pairs — truncation loss plus propagation), but its safety margin rests on the minimum observed gap between consecutive beacons from one tracker (2.77 s), which is empirical. The position bound is structural, so it wins.

Aid stations are not receivers. They carried an optional callsign until 2026-08-22, and one with a callsign was drawn on the Analyzer map as an iGate with its own receiver lines. The field has been removed: an aid stop is a place on the course, and a station that actually gates packets belongs in the iGates section, where it is described as what it is. A callsign left in an older event.yaml is ignored.

Data persistence: Beacon data is never deleted automatically. It persists across daemon restarts, page reloads, and server reboots until an operator explicitly uses Erase All Data (admin password + two-step confirmation). Erase All Data is scoped to the current event — it deletes only rows carrying that event's event_id, so recordings from other events are never touched. The SQLite database is excluded from the deploy rsync so a new deployment never wipes event data.

Per-iGate recording (undeduped, from the aggregation relay). aprs_daemon.py above reads the public APRS-IS feed, which is de-duplicated — so each beacon carries only one receiver (the first iGate to gate it), and you can't see which of our gates actually heard a tracker or how their coverage overlaps. A second recorder, relay_daemon.py (service analyzer-relay-daemon), fills that gap: it streams the aggregation relay's undeduped capture.jsonl from the VPS over SSH (a www-data-readable forced-command key that only tail -Fs the file) and, for the active event, inserts a beacon per (tracker, iGate) with receiver set to the gating gate (MARS-13, MARS-5, …). It writes the same beacons table and reuses the same event/watch-list logic; the Analyzer UI already draws a line from each beacon to its receiving iGate, so it renders the full "who heard whom" picture with no display change. Insertion is de-duped per (callsign, receiver, position) — a stationary tracker heard 30× by one gate is a single tracker→gate link, while a different gate or a new position is a new link. The Analyzer's Record control starts and stops both recorders together (flask_app.py/api/daemon), so the public-feed and per-iGate records are always captured for the same window. This only sees gates that have the relay enabled (see iGate Aggregation Relay); coverage grows as more gates are opted in.

Map & Controls

The Analyzer map is a Leaflet map using the same tile backgrounds configured in the event. All recorded beacons for the current event are loaded on page load and re-fetched on every auto-refresh cycle.

Beacon rendering:

Beacon type Color Line
Radio (via iGate) Red dot Red line from beacon → iGate
Cellular (mobile participant) Green dot Green line between consecutive positions

Show Full Path extends radio receiver lines through all intermediate digipeaters in the APRS path, not just the final iGate. When a single iGate is selected, lines route to that iGate/digipeater specifically.

Name labels (Show Names checkbox, default on): Each tracker's name is displayed in a black-on-white label at its last known position. iGate and aid station names are shown as permanent Leaflet tooltips on their map markers.

Controls modal (gear icon, lower-left corner):

Left column — Display

Control Function
Show All Times Toggle time tooltip visibility on all beacon dots
Show Full Path Draw lines through all digipeaters, not just the final iGate
Show Radio Beacons 🔴 Show/hide all radio (APRS) beacon dots and lines
Show Cellular Beacons 🟢 Show/hide all cellular (mobile app) beacon dots and lines
Show Courses Toggle GPX/KML/GeoJSON course overlays
Show Names Toggle name labels for trackers, iGates, and aid stations
Auto-Refresh Slider: poll interval from 30 s to 5 min (per-client; does not affect recording)
Beacon Time Range Two sliders trim the displayed beacon window; each slider shows the actual date/time of the first/last beacon in range. Because beacons are returned in global time order, the window is chronological — the end slider tracks the newest beacon across all trackers (not whichever tracker happened to sort last), and each track is drawn from every tracker's own previous point so interleaved ordering doesn't break the trails

Right column — Filtering

Control Function
Trackers Multi-select list of all tracked callsigns. Hybrid trackers (mobile + ham radio) appear as a single entry; selecting one shows both callsign streams. ⌘/Ctrl+click for multiple.
IGates Multi-select list of iGates and digipeaters. Filtering to one iGate shows only beacons received by that station.
Cellular Carrier Multi-select: All · AT&T · Comcast · Space Exploration · T-Mobile · Verizon · Other. Filters cellular beacons by the carrier stored in device_info.carrier in mobile_trackers.json. Radio beacons are unaffected.

Footer buttons

Button Function
Save Map Position Saves the current map center and zoom to localStorage as the default view for this event
Erase All Data… Deletes all beacon rows for the current event from SQLite (requires admin password + two-step confirmation)

Analyzer Key Files

All files live under /home/pi/analyzer/ on the server Pi.

File Purpose
src/flask_app.py Flask application: routes, auth, config loading, beacon enrichment, template rendering
src/aprs_daemon.py APRS-IS listener (public, deduped feed); inserts beacons into SQLite; reads tracker list from config.yaml and mobile_trackers.json
src/relay_daemon.py Per-iGate recorder; streams the aggregation relay's undeduped capture.jsonl from the VPS and inserts a beacon per (tracker, iGate). Uses relaycap_key (www-data-readable, forced-command SSH)
src/aprs_db.py SQLite wrapper: event management, beacon insert, deduplicated + globally time-sorted beacon fetch, recording time range queries
src/aprs.db SQLite database; excluded from deploys
src/templates/event_map.html Main live map page (Leaflet + controls modal); loads the shared player engine
src/static/session_player.js Shared rendering + playback engine (filtering, time-range sliders, track drawing) used by both the live map and exported sessions
src/auth_db.py Python auth library: validates marsaprs_session cookie against users.db

Configuration is read live from /var/www/html/admin/config.yaml (the active event symlink) and /var/www/html/mobile_trackers.json on every page load — no restart needed when the event or tracker list changes.

Analyzer Services

# Flask web app (always on)
sudo systemctl status analyzer
sudo systemctl restart analyzer
sudo journalctl -u analyzer -f

# Beacon recording daemons (both started/stopped together from the UI Record control)
sudo systemctl status analyzer-daemon        # public deduped APRS-IS feed
sudo systemctl status analyzer-relay-daemon  # per-iGate undeduped relay capture
sudo systemctl start analyzer-daemon analyzer-relay-daemon
sudo systemctl stop  analyzer-daemon analyzer-relay-daemon
sudo journalctl -u analyzer-daemon -u analyzer-relay-daemon -f

Logs: /var/log/analyzer/analyzer.log, /var/log/analyzer/daemon.log, and /var/log/analyzer/relay-daemon.log

The daemon restarts automatically on failure (30 s delay, 5 retries per 5 minutes). The web app restarts automatically on failure (10 s delay).

Analyzer API Endpoints

All endpoints require a valid marsaprs_session cookie with at least analyzer.view. Admin endpoints additionally require analyzer.admin.

Endpoint Method Permission Description
/ GET analyzer.view Redirect to current event URL
/event/<name> GET analyzer.view Main map page; always redirects to the current yaml event
/api/event_beacons/<name> GET analyzer.view All deduplicated beacons for the named event as JSON
/api/check_admin GET analyzer.view Returns {"ok": true} if user has analyzer.admin
/api/daemon GET analyzer.view Returns {"running": true/false} — current daemon status
/api/daemon POST analyzer.admin {"action": "start"\|"stop"} — starts or stops analyzer-daemon
/api/flush POST analyzer.admin Deletes all beacon rows for the current event

Backup, Recovery and Updates

Server Pi

The server Pi and the Transcriber are pulled nightly onto the Mac by backup/marsaprs-backup.sh, launched by the com.doug.marsaprs-backup launchd agent at 2:00 am. Snapshots land in ~/aprs-backups/<device>/YYYYMMDD-HHMMSS/, hardlinked against the previous night so unchanged files cost no extra disk, and pruned after 30 days. The backup covers event configs, tracker history, tickets, the NetBird address list, and the WiFi and admin credentials.

The Mac pulls rather than the Pis pushing, because the Mac sleeps. launchd runs a missed calendar job on the next wake, so a backup arrives late instead of never; and the machine that owns the schedule is the one that can tell "the Pi was unreachable" from "the backup never ran". ~/aprs-backups/STATUS.txt records the result of the last run, and a failure also raises a macOS notification.

To restore: run backup/marsaprs-restore.sh <device> on the Mac. It prints what would change and writes nothing unless you add --apply.

This replaced a nightly FTP push to ftp.w6sg.net, retired in August 2026. That job had not run since the day it was installed: its cron line redirected output into a root-owned directory the pi account could not write, so the shell failed the redirection and never executed the script — silently, every night, for eight weeks.

Display Pis

Each display Pi runs auto-update.sh nightly at 4:00 am. It downloads a tar archive of updated scripts from the server, applies them via rsync, updates WiFi credentials, and reboots at 4:10 am to pick up any changes.

To force an immediate update:

ssh pi@<ip> /home/pi/auto-update.sh

There is no backup for the display Pis since their data are all temporary.

iGates

Each iGate runs auto-update.sh nightly at 4:00 am. It downloads updated direwatch scripts and the WiFi credential list from the server, applies them, and restarts affected services. If direwolf.conf has changed it restarts direwolf.

To force an immediate update:

ssh pi@<ip> /home/pi/auto-update.sh

Run it twice when upgrading from an older build. auto-update.sh downloads and overwrites itself partway through, so a device running an older copy applies only the blocks that existed in that older script. Anything newer — version stamping, IGLOGIN promotion, relay enrolment, added cron entries — does not run until the second pass.

The symptom is a device that looks updated (wc -c /home/pi/auto-update.sh matches the current one) while config.php and direwolf.conf still report the old version and no relay sentinel appears. Just run it again. Seen across six gates on 2026-08-07 upgrading 5.1 → 5.2.

There is no backup for the iGates since their data are all temporary.


Log Rotation

Log rotation is configured via /etc/logrotate.d/aprs, installed by install.sh on iGates and the server.

iGates — logs in /var/log/direwolf/:

File Written by
console.log direwolf (RF decoding, APRS-IS forwarding)
watchdog.log igate-watchdog.sh (SDR, IP, internet checks)

Display Pis — logs in /home/pi/:

File Written by
update.log auto-update.sh (nightly update)

Server — multiple locations:

File Written by
/var/log/aprs-daemon/daemon.log aprsDaemon.php (APRS-IS connection, packet processing)
/var/log/netbird-poller.log netbird-poller.py (NetBird device polling)
/var/log/boot.log System boot messages (kernel + service startup output)

All the APRS-specific logs above are covered by /etc/logrotate.d/aprs (installed by install.sh). boot.log is managed by the system's default logrotate config. Logs are rotated daily, compressed, and seven days of history are retained.


Building & Deploying Devices

Master SD card images for each device type are kept offline. (They previously lived on ftp.w6sg.net, which was retired in August 2026 — record the current location here.) They include the results of running install.sh but not configure.sh — so packages, services, and scripts are pre-installed, but site-specific settings (callsign, location, hostname) are set when deploying each individual device.

Power Supply Checks (all Pis) — common/power-check.sh

Under-voltage is the most-misdiagnosed failure in this fleet. It presents as anything but power: reboot loops, SD corruption, a USB disk that will not enumerate, a Pi that "lost the network". Three separate wrong diagnoses (power twice, UAS, and RF) were chased before the supply was found, so the check is codified rather than remembered.

/home/pi/power-check.sh          # any Pi: igate, display, server, transcriber

It decodes vcgencmd get_throttled into words (bits 0–3 = happening now, bits 16–19 = latched since boot), counts kernel under-voltage lines over the last day, and on a Pi 5 decodes the USB-PD profile that was actually negotiated from /sys/firmware/devicetree/base/chosen/power/.

It is installed by all four deploy.sh scripts and both auto-update.sh paths, and runs nightly on the server at 04:20.

Read the negotiated profile, not the label on the brick. A Pi 5 needs 5 V at 5 A specifically, and "100 W" is a rating at 20 V — a 100 W supply that cannot do 5 A at 5 V is not a Pi 5 supply. The server took three supplies before one worked; the second failed progressively rather than looping (under-voltage events 3 → 6 → 12 in three minutes), and the third took it from 1,046 events/day to zero over five hours.

NetBird Setup Keys

As part of configuring any device, you will need a NetBird key. At the moment, the only way to get one is to email Doug Kaye at [email protected].

APRS Server

The server master image is a complete, fully-configured copy of the running server. Flashing it to a replacement Pi produces a working server immediately — no further steps needed.

  1. Download and flash the server master image using Apple Pi Baker or Raspberry Pi Imager.
  2. Insert SD card and boot. The server is ready.
  3. Configuration should only be necessary if something (such as a NetBird address) has changed from the standard setup.

Display Pis & iGates

  1. Download and flash the display Pi master image.
  2. Insert SD card, connect to your local network, and boot.
  3. Find the Pi's IP address (from your router or arp -a <hostname>).
  4. SSH in: ssh pi@<ip> (password: guacamole)
  5. Run the configuration wizard: bash /home/pi/configure.sh Expected hostnames: NetControl and BigTV.
  6. Reboot when prompted.

Creating New Master Images

APRS Server

The server master is simply a snapshot of the current running server. No install script is needed (and none is available — the script would be served by the very Pi being built).

  1. Shut down aprs-pi cleanly: sudo shutdown -h now
  2. Remove the SD card and copy it using Apple Pi Baker or dd.
  3. Reinstall the original card and reboot.

Display Pis

  1. Flash a fresh Raspberry Pi OS Desktop (64-bit, Trixie) to an SD card using Raspberry Pi Imager. In the OS customization dialog set:
  2. Model: Pi 4
  3. Hostname: displayClone
  4. Username / password: pi / guacamole
  5. WiFi: your local network
  6. SSH: enabled
  7. Timezone: as appropriate
  8. Insert the card into a Pi 4 and boot.
  9. Find the IP and SSH in.
  10. Download and run the install script: bash bash <(curl -fsSL https://marsaprs.org/display/install.sh) 2>&1 | tee install.log
  11. Do not run configure.sh — that step is done when deploying each device.
  12. Shut down: sudo shutdown -h now
  13. Copy the SD card using Apple Pi Baker — this is the new display Pi master.
  14. Store the image file with the other SD masters for later cloning.

Cloning a master is safe; cloning a deployed device is not. Step 5 is what makes the difference — a master has never run configure.sh, so it carries no NetBird enrolment, no operator name and a placeholder hostname. A card taken from a working display brings all of that with it, plus /var/lib/bluetooth/<adapter>/: on 2026-08-07 a Bluetooth mouse paired to a previous Pi failed with ConnectionAttemptFailed: Page Timeout on the new one, and nothing done to the mouse could fix it, because the stale bond was on the Pi. Two peers sharing one NetBird identity flap endlessly. If you must clone a deployed card, scrub the NetBird enrolment, /var/lib/bluetooth/*, hostname, autologin.txt, SSH host keys and machine-id before first boot.

Note the master will have no fleet WiFi list: install.sh skips that download unless /home/pi/.wifi-token already exists. Either add the token before step 4, or add it at deploy time and re-run /home/pi/update-wifi.php.

iGates

  1. Flash a fresh Raspberry Pi OS Desktop (64-bit, Trixie) to an SD card using Raspberry Pi Imager. In the OS customization dialog set:
  2. Model: Pi Zero 2 W
  3. Hostname: trackerClone
  4. Username / password: pi / guacamole
  5. WiFi: your local network
  6. SSH: enabled
  7. Timezone: as appropriate
  8. Insert the card into a Pi Zero 2 W and boot.
  9. Find the IP and SSH in.
  10. Download and run the install script: bash bash <(curl -fsSL https://marsaprs.org/igate/install.sh) 2>&1 | tee install.log
  11. Do not run configure.sh — that step is done when deploying each device.
  12. Shut down: sudo shutdown -h now
  13. Copy the SD card using Apple Pi Baker — this is the new iGate master.
  14. Store the image file with the other SD masters for later cloning.

Supporting Systems

NetBird Status Monitor

A web application at /netbird/ that monitors all registered Pi devices over the NetBird VPN, showing real-time health status and providing a browser-based SSH terminal.

Architecture

addresses.yaml  (device list, credentials)
toggle_state.json  (per-device toggle timestamps)
      │
      ├──▶  netbird-poller.py  ──writes──▶  stats.json
      │        │  UDP poll every N seconds
      │        ▼
      │     each device's NetBird IP : 1235
      │
      ├──▶  api.php  ◀── browser polls every N seconds
      │        │  merges YAML + stats.json + toggle_state.json
      │        ▼
      │     index.php  (main status page)
      │
      └──▶  admin.php  (device management)
               │
               ├──▶  save.php  (CRUD + toggle)
               └──▶  ssh_term.php  ──SSE──▶  ssh_stream.php
                                                   │  proc_open
                                                   ▼
                                              ssh_relay.py  (paramiko)
                                                   │  NetBird VPN
                                                   ▼
                                              remote device
File Role
netbird-poller.py UDP polling daemon (Python); sends requests to enabled devices, writes stats.json
api.php JSON endpoint; merges addresses.yaml, stats.json, and toggle_state.json; computes pending_until server-side
index.php Main status page; password-protected; polls api.php every N seconds
admin.php Device list CRUD; toggle switches; SSH terminal launch
save.php Handles all CRUD actions and toggle; computes and returns pending_until on toggle
yaml_lib.php Custom YAML parser/writer (no php-yaml extension required)
addresses.yaml Device configuration: name, host, ip, group, enabled, ssh_user, ssh_pass
stats.json Live poller output; read by api.php
toggle_state.json Per-device toggle timestamps (Unix epoch); written by save.php on every enable/disable
ssh_term.php Browser SSH terminal popup (xterm.js)
ssh_stream.php SSE relay; spawns ssh_relay.py via proc_open
ssh_relay.py Python/paramiko SSH relay; reads input queue, writes output to stdout
ssh_input.php Receives keystrokes from browser; appends to per-session queue file
ssh_resize.php Receives terminal resize events; writes to per-session resize file

Device Configuration (addresses.yaml)

- name: Muir Woods
  host: MARS-2            # APRS callsign / hostname used for status lookups
  ip: 100.101.197.70      # NetBird IP
  group: "via internet (WiFi or Ethernet)"
  enabled: true
  ssh_user: pi            # stored when first SSH login succeeds
  ssh_pass: ""            # stored only if "Remember password" is checked

The group field becomes a section header in both the admin and main pages. The host field is used for the hostname self-reporting endpoint. ssh_user and ssh_pass are written by save.php when a user authenticates via the SSH terminal.

Status States

Status Condition Color
Online Device responded to the last UDP poll Green
Pending Device was recently enabled or disabled; waiting for the change to take effect Blue
Offline Enabled but no response after the pending period has fully elapsed Red
Disabled enabled: false in addresses.yaml, and not in a pending window Gray

Pending applies to both transitions:

pending_until is computed entirely server-side by api.php and save.php:

$deadline     = (int)(ceil($toggled_at / 300) * 300) + 30;
$pending_until = $enabled
    ? $deadline + ($repeat_seconds * 3)   // enable: extra time for 3 missed polls
    : $deadline;                           // disable: deadline only

The browser compares Date.now() / 1000 < pending_until and renders the Pending badge accordingly. No scheduling logic lives in the client.

Stats Response Format

Each Pi runs stats-listener.php (iGate: stats-listener.php; display Pi / server: StatsRequestListener.php) as a systemd service listening on UDP port 1235. When polled by daemon.php, it responds with a single line of plain text.

Full response (default):

<hostname>  Load=<load>  Temp=<temp>  Mem=<disk>  Throttled=<throttled>  <NetBird-IP>  SSID=<ssid>  [Low Voltage][High Temp]

Short response (when the request contains the word short):

<hostname> Load=<load> <temp> Throttled=<throttled> SSID=<ssid> [Low Voltage][High Temp]
Field Source Notes
hostname hostname command
Load uptime 1, 5, and 15-minute load averages
Temp vcgencmd measure_temp e.g. temp=47.8'C
Mem du -sh ~ Home directory size; proxy for total disk use
Throttled vcgencmd get_throttled Hex bitmask; 0x00000 = normal
<NetBird-IP> netbird status Omitted in short form
SSID nmcli WiFi network name, or <Ethernet>
Low Voltage Throttled bit 0 Appended only when set
High Temp Throttled bit 3 Appended only when set

Example response as shown on the NetBird Status page:

0.02,0.06,0.11  40.4'C  128M  TerraceLan2  Low Voltage

Immediate Status Propagation

api.php merges addresses.yaml, stats.json, and toggle_state.json on every request, so toggling a device in the admin page is reflected on the status page on its very next poll — no poller cycle needed. save.php also returns pending_until directly in the toggle response so the admin page can render the correct Pending state immediately, before the next poll completes. A BroadcastChannel('aprs_netbird') message carries pending_until to the status page if it is open in the same browser.

Hostname Self-Reporting Endpoint

GET /netbird/?hostname=<callsign>

No authentication required. Returns a plain-text integer:

Value Meaning
1 Device is known and enabled: true
0 Device is known and enabled: false
-1 Device not found in addresses.yaml

iGates use this to determine whether to transmit APRS beacons. They poll it on 5-minute clock boundaries.

SSH Terminal

The SSH button (admin page, active when device is Online) opens a popup with a full terminal powered by xterm.js.

Security: If both ssh_user and ssh_pass are stored, PHP mints a one-time session token server-side — the password is never sent to the browser. If credentials are missing, a login form is shown; on success the credentials are saved to addresses.yaml.

Relay architecture: 1. ssh_stream.php spawns ssh_relay.py via PHP proc_open. 2. ssh_relay.py opens a paramiko PTY shell to the device. 3. SSH output → base64 lines → PHP → SSE → browser → xterm.js. 4. Keystrokes → ssh_input.php → queue file → relay → SSH channel. 5. Terminal resize events → ssh_resize.phpchan.resize_pty(cols, rows).

Dependency: sudo apt-get install python3-paramiko

Poller (netbird-poller.py)

Run by netbird-daemon.service. Polls enabled devices every repeat_seconds (default 60) when there are active viewers. Writes responses to stats.json via atomic rename. Requires 3 consecutive missed polls before marking a device Offline.

sudo systemctl status netbird-daemon
sudo systemctl restart netbird-daemon
sudo journalctl -u netbird-daemon -f

Log: /var/www/html/netbird/daemon.log

API: api.php

No authentication required. Polled by the main page every 15 seconds.

GET api.php — returns full device status for all devices:

{
  "last_send_ts": 1779080795,
  "repeat_seconds": 60,
  "daemon_running": true,
  "devices": [
    {
      "ip": "100.101.197.70",
      "hostname": "MARS-2",
      "name": "Muir Woods",
      "group": "via internet (WiFi or Ethernet)",
      "enabled": true,
      "online": true,
      "pending_until": null,
      "last_request": 1779080795,
      "last_response": 1779080795,
      "response_data": "MARS-2 Load=0.19 Temp=47.8°C ..."
    }
  ]
}

pending_until is a Unix timestamp (or null). The browser renders Pending while Date.now() / 1000 < pending_until.

API: save.php

Action Auth Method Key Parameters Description
add_device required POST name, host, ip, group, enabled Append device
update_device required POST orig_ip, name, host, ip, group, enabled Edit device
delete_device required POST ip Remove device
toggle_device required POST ip Flip enabled; returns {ok, enabled, pending_until}
save_config open POST repeat_seconds Update poll interval
save_ssh_creds required POST ip, ssh_user, ssh_pass Update credentials

WiFi Manager

A web application at /wifi/ for managing the shared list of WiFi credentials distributed to all MARS Pi devices nightly.

Architecture

/var/www/html/wifi/wifi.yaml  (on aprs-pi — master copy)
     │
     ├──▶  /igate/wifi/get.php?token=<token>    ──▶  iGates
     ├──▶  /display/wifi/get.php?token=<token>  ──▶  Display Pis
     └──▶  update-wifi.php                      ──▶  Server Pi (nightly via cron)
File Role
server/www/wifi/index.php Web UI for viewing and editing the credential list
server/www/wifi/get.php Token-authenticated download endpoint for iGates
display/www/wifi/get.php Token-authenticated download endpoint for display Pis
/home/pi/update-wifi.php Applies wifi.yaml via nmcli on each Pi
/home/pi/.wifi-token Shared authentication token (required on all Pi types)

Every edit and drag-drop reorder immediately writes wifi.yaml via the ?save endpoint. There is no manual save step.

Update Schedule

Device When credentials update
iGates Nightly at 4:00 am via auto-update.sh
Display Pis Nightly at 4:01 am via auto-update.sh
Server Pi Nightly at 4:00 am via cron

Note that display Pis (Net Control and Big TV) are likely not running at 4am and therefore must be updated manually.

Token Authentication

Each Pi has /home/pi/.wifi-token. Download requests without a valid token return HTTP 403. If the token is missing from the server:

tar -xzf /var/www/html/igate/files.tar.gz -C /tmp ./home/.wifi-token
cp /tmp/home/.wifi-token /home/pi/.wifi-token

Safe Download Pattern

auto-update.sh downloads to a temp file and validates content before replacing the live copy:

wget -qO /tmp/wifi.yaml.new "$BASE/wifi/get.php?token=$(cat /home/pi/.wifi-token)"
grep -q "^- name:" /tmp/wifi.yaml.new && mv /tmp/wifi.yaml.new /home/pi/wifi.yaml

update-wifi.php exits without making any nmcli changes if zero entries are parsed, preventing accidental deletion of all configured networks.


Appendix


File Formats

event.yaml

Each event's configuration lives in events/<EventName>/event.yaml. config.yaml is a symlink to the active event's file. aprsDaemon.php and index.php monitor it via filemtime; changes take effect within ~5 seconds without restarting the daemon.

event
event: Dipsea 2026
legend

Optional HTML displayed in the lower-left corner of the map in kiosk mode (ie, for the Big TV). The value is injected as innerHTML, so any HTML tags, inline styles, and attributes are supported. In the YAML file, use \n inside a quoted string to break across lines for readability.

legend: "<b>Race Day — June 14</b><br>Start: 7:00 AM at Mill Valley"
trackers
trackers:
  - callsign: W6SG-4    # APRS callsign
    id: S2              # short label on the map marker and sidebar
    name: Rob           # full name shown in the sidebar
tracker_style
tracker_style:
  icon: circle          # circle, square, diamond, triangle, star, cross, person
  label_color: "#000000"

Marker size scales with zoom level: full at zoom ≥ 14, down to 30 % at zoom 7 and below.

map
map:
  lat: 37.87255
  lon: -122.544079
  zoom: 13
backgrounds
backgrounds:
  - name: OpenStreetMap
    url: https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
    attribution: "&copy; <a href='https://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors"
    max_zoom: 19

max_zoom caps the Leaflet zoom level for this tile layer. Omit to default to 19.

courses

GPX, KML, GeoJSON, or JSON overlays. Files must be in the event directory.

courses:
  - name: 13 Mile
    file: events/Dipsea 2026/13_Mile.geojson
    color: "#2196f3"

Supported extensions: .gpx .kml .geojson .json

aidstations
aidstations:
  - name: Muir Woods Clubhouse
    lat: 37.89799
    lon: -122.56667
igates
igates:
  - name: Pt. Reyes Station
    lat: 38.068051
    lon: -122.808013
section_visibility

Default visibility of each sidebar section on page load.

section_visibility:
  trackers: true
  courses: true
  aidstations: true
  igates: true
  backgrounds: true
mobile

Controls mobile location sharing for participants.

mobile:
  enabled: true              # show Share Location button in the mobile drawer
  pin: "1234"                # PIN participants must enter (omit to require no PIN)
  root: K6DRK                # base APRS callsign; participants get <root>-01, -02, etc.
  messaging_password: zippy  # enables Messages button on web map; omit to disable messaging

When enabled is true, any visitor on a mobile device sees a Share Location button in the drawer footer. Participants enter their name (pre-filled from localStorage on return visits) and the PIN (both shown as plain text), then tap Share Location to start sharing in unknown (?) mode. Smart Track automatically determines the activity and adjusts the beacon interval based on GPS speed within ~90 seconds (see Smart Track). The button changes to Sharing while active; tapping it opens a panel to stop sharing. The server assigns a unique callsign (<root>-NN) and a session token. Position updates are injected directly into the APRS-IS network, so mobile participants appear on this map and on external APRS sites (aprs.fi, CalTopo) exactly like any other tracker. Session state is stored in mobile_trackers.json.


Server

Requirements

Configuration (config.yaml)

config.yaml is a symlink to the active event's event.yaml. Changes take effect within ~5 seconds without restarting the daemon. See File Formats for the full event.yaml field reference.

Event Directory Structure

Each event lives in events/<EventName>/. config.yaml is a symlink to the active event.

events/
├── Dipsea 2026/
│   ├── event.yaml              # event configuration
│   ├── Dipsea.json             # course overlay
│   └── tracker_history.yaml    # auto-generated; not committed to git
└── Marin Ultra Challenge 2026/
    ├── event.yaml
    ├── 13_Mile.geojson
    ├── 18_Mile.json
    └── 6_Mile.json
config.yaml → events/Dipsea 2026/event.yaml   (symlink)

tracker_history.yaml — auto-generated by aprsDaemon.php. Stores the most-recent beacon positions per tracker. Pruned when a tracker is removed from the config. Also pruned for a mobile participant's callsign when they start a new session (?mobile=join), so stale breadcrumbs from a prior session never appear.

Retention follows breadcrumb_count. The daemon keeps $breadcrumbRetain positions per tracker — read from breadcrumb_count in config.yaml on every config reload, clamped to 10–100. The daemon is the binding limit: both clients ask for breadcrumb_count points and can only draw what the daemon stored, so retention must be at least as large as the slider. This was formerly hard-coded to 10 in four places while the Admin slider ranged to 100, which silently clipped every trail to 10 dots on the web map and the iOS app alike no matter what the slider said. Raising the slider takes effect on the next config reload without a daemon restart, but only fills going forward — discarded positions cannot be recovered.

Startup ordering note: loadMobileSessions() must run before readTrackerHistoryFile() at daemon startup. loadMobileSessions() calls unset($trackerHistory[$cs]) for any callsign not yet in $trackers; if history were loaded first, all mobile callsigns would be cleared (they are absent from $trackers until loadMobileSessions() adds them). Running sessions load first makes the unset() calls harmless (the array is still empty).

Minimum-distance filter: Before appending a new breadcrumb, the daemon computes the haversine distance from the previous breadcrumb. If the new position is within 30.48 m (100 feet), it is discarded — lastUpdate is still updated (so the tracker doesn't appear stale), but no new breadcrumb is added and trackers.json position fields are not changed. This filter applies to both regular and mobile trackers and covers both web and native-app clients, since both read the same server-side files.

APRS-IS injection deduplication: ?mobile=update now gates each call to injectAprsPacket() with a server-side dedup check. The last injected position and timestamp (aprs_lat, aprs_lon, aprs_ts) are stored per tracker in mobile_trackers.json. A new packet is injected only when the tracker has moved ≥ 30 m from the last injected position or ≥ 5 minutes have elapsed since the last injection (heartbeat). This does not affect what is stored, displayed, or returned to clients — every ?mobile=update is still processed fully; only the outbound TCP connection to noam.aprs2.net is skipped when the position hasn't changed meaningfully.

Running the Daemon

Direct (foreground):

php aprsDaemon.php
Option Default Description
server=<host> noam.aprs2.net APRS-IS server hostname
config=<file> config.yaml Path to configuration file
trackerstatus=<file> trackers.json Path to tracker status output file
debug off Print every received APRS line to stdout

Via systemd:

sudo systemctl status aprs-daemon
sudo journalctl -u aprs-daemon -f
sudo systemctl restart aprs-daemon

Log: /var/log/aprs-daemon/daemon.log

The wrapper script (aprs-daemon.sh) handles PID-file-based duplicate-start protection. systemd restarts the daemon on failure (10 s delay, 5 retries per 5 minutes).

Tracker colors:

Color Condition
Green Heard within the last 2 minutes
Blue Heard within the last 5 minutes
Red Not heard for more than 5 minutes

APRS packet decoding:

Format Description
Uncompressed DDmm.mmN/DDDmm.mmW — degrees and decimal minutes
Compressed Base91 4-byte Base91-encoded lat/lon in the payload
Mic-E Latitude in the 6-character AX.25 destination field; longitude in payload

The daemon maintains a 60-second socket receive timeout; if no data arrives (e.g., network change) the socket is closed and a new connection established automatically.

A watchdog check runs at the top of every main loop iteration. If no data has been written in more than 3 minutes (i.e., the APRS-IS connection stays ESTABLISHED but delivers no packets), the daemon reconnects and resets the timer. This prevents a silent multi-hour freeze that occurs when the APRS-IS server goes quiet without closing the TCP connection.

// At the top of the while(TRUE) loop:
if ((time() - $lastWriteTime) > 180) {
    connectToAprsServer();
    $lastWriteTime = time();
}

Web Interface

The map is at https://marsaprs.org/. Layout adapts automatically:

Mobile full-screen: On iOS and iPadOS, index.php shows a one-time nudge at the bottom of the screen on first visit. Safari users see "tap Share ⬆ → Add to Home Screen"; Chrome (CriOS) users see "tap ⋯ → Add to Home Screen". Dismissing the nudge via ✕ sets localStorage['a2hs-dismissed'] so it never reappears. Once installed as a home screen app (standalone mode), the page runs without address bar or tab bar, enabled by the apple-mobile-web-app-capable and apple-mobile-web-app-status-bar-style meta tags. On Android Chrome, the address bar auto-hides on scroll; no nudge is needed.

Sidebar sections (desktop): Each section header has a visibility checkbox (show/hide map objects) and a click-to-collapse toggle. Sidebar width is adjustable by dragging the divider; width is saved in localStorage.

Desktop lower-right corner controls: On non-touch screens, two Leaflet controls appear in the lower-right corner of the map (above the zoom controls): - ↺ Reset Map — resets the map view, clears breadcrumbs and the Origin marker (same as the sidebar Reset Map button). - ⊕ My Location — requests the browser's geolocation and pans to the result; blinks while locating; shows an error tooltip if geolocation fails or times out (8 s timeout).

Mobile drawer: Sections are collapsible accordions. Visibility checkboxes work the same as desktop. Footer buttons: Save Map, Admin, User Guide, Kiosk Mode, Clients, and (when mobile.enabled) Share Location / Sharing. The About section of the drawer shows the user's assigned APRS callsign while sharing is active. The floating reset button (↺) pinned to the map also closes the drawer if it is open before recentering.

Mobile tracker markers: Mobile participants (sharing via Share Location) are rendered as rounded squares on both the map and in the sidebar, distinguishing them from regular APRS trackers (which use the configured icon shape). The shape is determined by t.mobile in the tracker JSON. In index.php, the icon is selected as t.mobile ? 'square' : trackerStyle.icon; the sidebar dot gets border-radius: 3px for mobile and 50% for others. In the native Flutter app, each marker also shows the tracker ID as a text label directly next to the dot on the map.

Tracker clicks (desktop — three-click cycle): 1. Blink tracker + show beacon history (up to 10 positions as dots + dashed polyline with directional arrows; trail auto-refreshes as tracker moves) 2. Zoom to tracker's last position (zoom 15) 3. Reset map to default view

Tracker touches (mobile): - Short tap — blink + show history; drawer stays open - Long press (≥ 500 ms) — blink + history + close drawer + zoom to position

Origin, distance, bearing: Right-click sets a red Origin marker. Left-click anywhere shows distance (miles) and bearing from Origin. Origin clears on Reset Map.

Map layer z-order (top to bottom):

Layer z-index
Trackers 450
Aid Stations 430
Courses 410
iGates 390

Kiosk mode: ?kiosk=1 or click Kiosk Mode. Shows Trackers, Aid Stations, and iGates only. Footer: Sidebar toggle, Reset Map, Exit (navigates to localhost:8080/exit).

Live polling: The browser polls ?json every 5 seconds when viewing the default event and when the tracker list has not been locally edited. Only tracker positions and timestamps are updated; all other config sections are left unchanged by polls.

Admin Interface

Open https://marsaprs.org/admin/. Requires a user account with admin.view or admin.edit (see Authentication). Session lasts 24 hours.

Editable sections: Trackers (callsign, ID, name), Tracker Style (icon, color), Map Default View, Default Section Visibility, Backgrounds, Courses, Aid Stations, iGates, Legend, Mobile Tracking (enabled, PIN, root callsign; messaging password; participant list with rename/block/remove), Beacon Settings (upload interval and distance threshold per activity mode).

Messages modal: The 💬 Messages button opens a modal showing the full message log for the current event (read from messages.db), with Export and Delete All Messages buttons. Delete All Messages wipes the event's messages and photos from the SQLite store after a two-step confirmation and is gated by the messages.manage permission.

Tracker Δ column: Each tracker row shows a read-only Δ field — the minimum interval between consecutive beacons in the last 10 received, displayed as M:SS. Shows until at least 2 beacons have been received. The column refreshes automatically every 30 seconds; the last refresh time is shown next to the Trackers section heading. Deleting and re-adding a tracker resets its history — avoid this while trying to verify the beacon interval.

Header/footer buttons:

Button What it does
Update Saves local-only sections to localStorage; returns to map. Does not write to server.
Save Writes config to the current event's event.yaml. Does not change the active event symlink.
Save as Default Event Writes config + re-points config.yaml symlink. All users pick up changes within ~5 s.
Exit Leaves without saving.
Sign Out Destroys the session.

Local vs server sections:

Classification Sections
Local (Update saves) Event Name, Legend, Tracker Style, Map Default View
Server-only Trackers, Backgrounds, Courses, Aid Stations, iGates

Event management: Save as Default Event creates or overwrites a named event directory and re-points the symlink. Load shows all events newest-first; Load & Activate switches the active event. Delete removes the event directory permanently. Events can be locked (locked: true in event.yaml); locked events reject saves without the admin password.

Import / Export:

Section Export Import
Event YAML YAML
Trackers YAML, CSV YAML, CSV
Aid Stations YAML, CSV, GPX YAML, CSV, GPX, KML, GeoJSON, JSON
iGates YAML, CSV, GPX YAML, CSV, GPX, KML, GeoJSON, JSON
Courses YAML, CSV YAML, CSV, GPX, KML, GeoJSON, JSON

Smart lat/lon paste: Pasting a combined coordinate string (e.g., 37.7749, -122.4194 from Google Maps) into either lat or lon field auto-splits and populates both fields.

Tile Provider Browser: The ⊞ button opens a grid of all free Leaflet tile providers with thumbnail previews. Clicking a provider inserts it into Backgrounds with all fields pre-filled.

Manage Location Files: Sortable table of course files across all events. Upload (drag-and-drop or file picker), rename, delete, or add to the current event.

API Endpoints — index.php

All endpoints return Cache-Control: no-store unless noted. ETag/304 caching is used for ?json, ?config, and ?history.

Endpoint Method Description
?json GET Current tracker state from trackers.json, plus iGate and aid station last-beacon timestamps
?config GET Parsed active configuration (all sections)
?history GET Beacon history — 5 most-recent positions per tracker
?clientstatus GET Apache worker stats and connected client IPs
?mobile=join POST Create a mobile participant session; assigns callsign and returns token. Clears any prior history for that callsign.
?mobile=update POST Heartbeat + inject APRS-IS position packet; refreshes session timestamp. Accepts ack_ids to clear delivered messages. Returns pending messages in response body. Returns 404 if the session was removed or blocked.
?mobile=leave POST End the participant session and remove from mobile_trackers.json.
?mobile=message POST Send a text message from a mobile participant to web operators. Body: {token, text, to?}to is an operator name for per-operator addressing, omitted (legacy) → web (all operators). Stamps the sender's latest beacon onto the log entry as lat/lon/pos_ts. Returns {ok, id}.
?mobile=poll POST Lightweight message poll without updating position. Body: {token, ack_ids?}. Returns {messages: [...]} or 404.
?mobile=msghistory POST Fetch full message history for the session callsign. Body: {token}. Returns {messages: [...]} oldest-first.
?mobile=auth POST Validate the event password. Body: {password}. Returns 200 if accepted.
?messaging=subscribe POST Subscribe as a web operator. Body: {name, password}. Returns {token, name} or {error}.
?messaging=send POST Send a message from a web operator to a mobile tracker. Body: {web_token, to, text}. to is a callsign or "*" for broadcast, taken from the compose To: dropdown.
?messaging=poll GET Poll for new messages directed to web operators. Params: web_token, since_id. Returns {messages, last_id}.
?messaging=rename POST Update operator's display name. Body: {web_token, name}. Updates web_sessions.json and returns {ok}.
?messaging=history GET/POST Full message log for the current event from messages.db (unfiltered — includes other operators' traffic). Param/body: web_token. Returns {messages, last_id, can_delete_all}.
?messaging=delete_all POST Erase the message log. Body: {web_token}. Requires a valid subscription and a signed-in account with messages.manage; otherwise 403. Preserves the ID counter and clears all pending_msgs. Returns {ok, deleted}.
?autologin GET Set a PHP session for the current event (no password required); if operator param is set, also sets the operator name in session. Redirects to clean URL.

API Endpoints — admin/index.php

All endpoints require an active session (HTTP 401 if not authenticated).

Endpoint Method Description
?logout GET Destroy session and redirect to login
?load GET Active event config as JSON
?save POST Write full config to config.yaml
?versions GET All saved events, newest-first
?saveonly POST Write config to named event; do not change symlink
?saveversion POST Write config + re-point config.yaml symlink
?loadversion&name=<N> GET Named event's config as JSON
?deleteversion POST Delete event directory
?locationfiles GET Course files in the active event directory
?alllocationfiles GET Course files across all events
?upload POST Upload a course file to the active event directory
?renamefile POST Rename a course file
?deletefile POST Delete a course file
?setactiveevent POST Re-point config.yaml without saving content
?bglib GET Deduplicated background tile layers from all events
?beacondeltas GET Min inter-beacon gap per tracker callsign (from tracker_history.yaml)
?togglelock POST Lock or unlock an event (requires password)
?messages GET Return the full message log for the current event from messages.db as JSON.
?delete_messages GET Delete all messages (and photos) for the current event from messages.db. Gated by messages.manage; used by the Delete All Messages button in the admin Messages modal.

File Permissions

Web root /var/www/html/ is owned pi:www-data with mode 775. The daemon runs as www-data with UMask=0002; files it creates get mode 664.

File Needs write access Created by
trackers.json www-data (daemon) daemon on first run
igates.json www-data (daemon) daemon on first iGate activity
aidstations.json www-data (daemon) daemon on first aid station activity
config.yaml www-data (admin page) manual / admin Save as Default Event
events/<E>/event.yaml www-data (admin page) admin Save as Default Event
events/<E>/tracker_history.yaml www-data (daemon) daemon on first beacon
Course files (*.gpx, etc.) www-data (upload) admin upload or rsync

To repair ownership after manual file operations:

sudo chown -R pi:www-data /var/www/html/events/
sudo chmod -R g+w /var/www/html/events/

Display Pi

Services

All services start automatically at boot via systemd.

Service Port Script Purpose
x11vnc 5901 VNC remote desktop (password: guacamole)
kill-server 8080 (localhost) kill-server.py GET /exit kills Chromium
stats-listener 1235 UDP StatsRequestListener.php Responds to health-check polls
aprs-monitor aprs-monitor.sh Polls marsaprs.org every 30 s; relaunches Chromium if unreachable
wifi-watchdog wifi-watchdog.sh Checks WiFi every 30 s; calls wifi-restored.sh on reconnect
lightdm X11 display manager; required for Chromium and VNC

Crontab

# MARS APRS Display Pi
# Check every 5 minutes whether to enable/disable NetBird
*/5 * * * * /home/pi/check-netbird.sh >> /tmp/checknetbird.log 2>&1
# Enable NetBird after any reboot
@reboot /home/pi/netbird-up.sh
# Nightly auto-update at 4:01am
1 4 * * * /home/pi/auto-update.sh >> /home/pi/update.log 2>&1
# Nightly reboot at 4:10am (after updates)
10 4 * * * sudo reboot

Kiosk Autostart

LXDE autostart (/home/pi/.config/lxsession/rpd-x/autostart) calls ~/start-kiosk.sh rather than launching Chromium directly:

@xrdb -merge ~/.Xresources
@/home/pi/start-kiosk.sh

~/start-kiosk.sh handles URL construction (auto-login), cursor size, and Chromium launch:

#!/bin/bash
URL="https://marsaprs.org/"
if [ -f ~/autologin.txt ]; then
    mapfile -t lines < ~/autologin.txt
    operator="${lines[0]:-}"
    URL="https://marsaprs.org/?autologin"
    if [ -n "$operator" ]; then
        enc_op=$(python3 -c "import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))" "$operator")
        URL="${URL}&operator=${enc_op}"
    fi
fi
export XCURSOR_SIZE=48
rm -f ~/.config/chromium/Singleton*
exec chromium --password-store=basic --kiosk --noerrdialogs --disable-infobars \
    --disable-dev-shm-usage --incognito \
    --disable-features=BlockInsecurePrivateNetworkRequests \
    --user-data-dir=/tmp/chromium \
    "$URL"

Useful Commands

# Check all services
sudo systemctl status x11vnc kill-server stats-listener aprs-monitor wifi-watchdog

# Restart Chromium in kiosk mode
sudo -u pi DISPLAY=:0 XAUTHORITY=/home/pi/.Xauthority \
  chromium --password-store=basic --kiosk --noerrdialogs \
  --disable-infobars --disable-dev-shm-usage --incognito \
  --disable-features=BlockInsecurePrivateNetworkRequests \
  --user-data-dir=/tmp/chromium \
  'https://marsaprs.org/' &

add-wifi.php — Adds a WiFi network to wifi.conf on the local device. Accepts name, SSID, and password as arguments (or prompts interactively). Hashes the password via wpa_passphrase and appends the entry to wifi.conf, then calls update-wifi.php to apply it immediately. Available on all Pi types.

php /home/pi/add-wifi.php "Home Network" "MySSID" "MyPassword"

Note: The addition applies immediately but will be overwritten the next time auto-update.sh runs (nightly). Use the WiFi Manager web UI (/wifi/) to make permanent changes.


iGate

Crontab

# Health watchdog: SDR + IP every minute, internet every 5 min
* * * * *    /home/pi/igate-watchdog.sh
# Nightly update at 4:01am
1 4 * * *    /home/pi/auto-update.sh
# Nightly reboot at 4:10am (after updates)
10 4 * * *   sudo reboot
# Check every 5 minutes whether to enable/disable NetBird
*/5 * * * *  /home/pi/check-netbird.sh
# Enable NetBird after any reboot
@reboot      /home/pi/netbird-up.sh

direwolf.conf Key Directives

Directive Example Purpose
MYCALL MYCALL MARS-5 Station callsign used on RF and APRS-IS
IGLOGIN IGLOGIN K6DRK 12345 APRS-IS login callsign and passcode
PBEACON PBEACON lat=37.96 long=-122.54 comment="iGate 5.0 by K6DRK, Richmond CA" Position beacon
FILTER FILTER 0 IG t/p & ! d/* APRS-IS server-side packet filter

Run /home/pi/configure.sh to set all of these interactively.

File Permissions

All iGate scripts live in /home/pi/, owned by pi. The direwolf log directory (/var/log/direwolf/) is owned by pi for the watchdog to write to.


Pi-Tools

These utility scripts live in their device source directories on the Mac (display/home/, display/systemd/, server/bin/, server/home/, server/systemd/) and are installed to /home/pi/, /usr/local/bin/, and /etc/systemd/system/ by install.sh as part of the standard device setup.

All Pis

add-wifi.php — Adds a WiFi network to the local wifi.conf. Takes name, SSID, and password as arguments or prompts interactively. Hashes the password with wpa_passphrase, appends the entry, and calls update-wifi.php to apply it. The change is immediate but will be overwritten by the next nightly auto-update.sh — use the WiFi Manager web UI for permanent additions.

wifi-watchdog.sh / wifi-watchdog.service — Generic WiFi watchdog. Checks connectivity every 30 seconds; calls /usr/local/bin/wifi-lost.sh on loss and /usr/local/bin/wifi-restored.sh on restore. install.sh installs and enables both. To install manually after the script has been placed in /home/pi/:

sudo cp ~/wifi-watchdog.sh /usr/local/bin/
sudo chmod +x /usr/local/bin/wifi-watchdog.sh
sudo systemctl daemon-reload && sudo systemctl enable --now wifi-watchdog

Display Pi

set-hostname.sh — Changes the hostname everywhere it needs to be set so the change persists across reboots (updates /boot/firmware/user-data, /etc/hostname, /etc/hosts, kernel UTS name, and hostnamectl).

sudo ~/set-hostname.sh <new-hostname>
exec $SHELL   # refresh the shell prompt

kill-server.py — Minimal Python HTTP server on localhost:8080. GET / returns a "Connecting…" page that auto-redirects when marsaprs.org is reachable. GET /exit kills Chromium. Used by the map's kiosk Exit button (via window.location.href to bypass Chrome's Private Network Access preflight).

aprs-monitor.sh — Polls marsaprs.org every 30 seconds. If unreachable, kills Chromium and relaunches it pointing to localhost:8080. When the site is reachable again, the connecting page auto-redirects.

wifi-restored.sh (display Pi hook) — Called by wifi-watchdog.sh when WiFi is restored. Kills Chromium and relaunches it via localhost:8080.

start-aprs.desktop — Desktop launcher; kills any running Chromium and relaunches in kiosk mode. install.sh places it in /home/pi/; copy it to the Desktop manually:

cp ~/start-aprs.desktop ~/Desktop/start-aprs.desktop
chmod 644 ~/Desktop/start-aprs.desktop   # must NOT be executable on Trixie

On Pi OS Trixie, also suppress the "Executable Script" dialog system-wide:

mkdir -p ~/.config/libfm
printf '[config]\nquick_exec=1\n' > ~/.config/libfm/libfm.conf

Server Pi

wifi-restored.sh — Called by wifi-watchdog.sh when WiFi is restored on aprs-pi. Restarts the APRS daemon to reconnect to APRS-IS on the new interface.

aprs-daemon.service / aprs-daemon.sh — Systemd service unit and wrapper script for the APRS daemon. See Running the Daemon.


Testing

A regression test suite covers the logic that is testable without live hardware: APRS parsers, YAML config read/write, tracker history file I/O, and the pure JavaScript utility functions. Hardware-dependent code (radio, SDR, GPIO, TFT display) is not tested here.

Prerequisites

Install PHPUnit (PHP tests) and Jest (JavaScript tests) once:

cd ~/marsaprs/map
composer install                # installs PHPUnit into map/vendor/

cd tests/js
npm install                     # installs Jest into map/tests/js/node_modules/

Both tools are listed as dev dependencies (composer.json / map/tests/js/package.json) and are excluded from the production server — tests/ and vendor/ are in the exclude list of both sync-to-pi.sh and server/deploy.sh. Keep the two lists in step: deploy.sh lacked these two exclusions until 2026-08-08, so a full deploy pushed ~49 MB of PHPUnit and test fixtures into the live web root, where individual files were served (directory listings were not — Apache returns 403). There are no production Composer dependencies at all; composer.json is require-dev only, so nothing at runtime needs vendor/.

Running the PHP tests

From map/:

./vendor/bin/phpunit -c tests/phpunit.xml

Expected output: OK (90 tests, 159 assertions)

Test class What it covers
AprsParserTest parseAprsPosition() — uncompressed, Base91 compressed, and Mic-E position formats; malformed packets
ConfigParseTest parseConfigYaml() and yamlScalar() — all config sections, edge cases, missing files
YamlLibTest yaml_lib.phpyamlVal, yamlStr, loadDevices/saveDevices roundtrip
TrackerHistoryTest readTrackerHistoryFile() / writeTrackerHistoryFile() — roundtrip, the breadcrumb_count-driven retention cap (including counts above 10), missing file
AdminConfigTest buildConfigYaml()parseConfigYaml() roundtrip — all sections, special chars, booleans

Test files live in map/tests/php/. Fixtures (sample YAML files) are in map/tests/fixtures/.

Running the Python tests

Standalone scripts — no framework, run them directly, exit 0 is a pass:

python3 igate/tests/test_isproxy.py          # direwolf gets an upstream on reconnect
python3 transcriber/tests/test_transcriber.py

The Transcriber suite runs the whole channel pipeline without a radio and without whisper: arecord and sox are skipped via --spool-only, and whisper is a stub script whose output the test chooses, so the filters can be driven deliberately. The two that earn their keep are the ones guarding what reaches the log — squelch noise transcribed as "Thank you." must produce nothing, and a genuine transmission through the same path must produce exactly one entry. Without that second test the first passes for free, and it did: an early version discarded everything because the clip looked like one sox was still writing, and every filter assertion passed without a filter ever running.

transcriber/deploy.sh runs this suite and refuses to deploy if it fails.

Running the JavaScript tests

From map/tests/js/:

npx jest

Expected output: Tests: 59 passed, 59 total

Function tested Cases
esc(s) All five HTML special characters; null/undefined coerced to ''
relativeTime(ts) "just now", seconds, minutes, hours; boundary conditions at 10 s, 60 s, 3600 s
haversineDistance() Same point = 0; SF→LA; SF→NYC; pole-to-pole
bearingTo() Due N/S/E/W; result always in [0°, 360°)
compassDir() All 16 cardinal/intercardinal points; 359° wraps to N
formatAprsPath() Q-code expansion; digipeated hops; unknown hops; HTML escaping; empty/null path

The JS functions (esc, relativeTime, haversineDistance, bearingTo, compassDir, Q_LABELS, formatAprsPath) live in map/utils.js and are loaded by map/index.php via <script src="utils.js">. The module.exports shim at the bottom of utils.js allows Jest to import them in Node without a browser.

Scope

In scope Out of scope
APRS position parsers APRS-IS socket connection
YAML config read/write iGate (RTL-SDR, direwolf, GPIO)
Tracker history file I/O Display Pi (TFT screen)
Admin config serialiser/parser roundtrip Leaflet map UI interactions
Pure JS utility functions