Skip to content

Add APRS bulletin board#345

Open
Russell-KV4S wants to merge 1187 commits into
chrissnell:mainfrom
Russell-KV4S:feature/aprs-bulletin-board
Open

Add APRS bulletin board#345
Russell-KV4S wants to merge 1187 commits into
chrissnell:mainfrom
Russell-KV4S:feature/aprs-bulletin-board

Conversation

@Russell-KV4S

Copy link
Copy Markdown

Summary

  • Full APRS bulletin board: receive (BLN0-9) and announcement (BLNA-Z) packets, display them on a dedicated Bulletins page with unread badges, and send outbound bulletins with automatic retransmit scheduling
  • Outbound send flow: 3-send burst at 30-second intervals on creation (per Bruninga/APRS decay algorithm), then a stable retransmit interval set per-bulletin at compose time (1-20 min, default 20; set to 0 for burst-only with no further retransmits)
  • Burst-only bulletins exhaust after 3 sends so the row shows Complete naturally
  • Digipeater path for bulletins and messages is a shared station-level setting in Messaging settings (WIDE1-1,WIDE2-1 default), with improved UI copy explaining why it is global
  • APRS-IS forward: if an iGate sender is wired, bulletins are also forwarded directly to APRS-IS via TNC2 format; ErrNotEnabled is silently swallowed for RF-only stations
  • Windows exe icon embedded via goversioninfo; Makefile bump targets now regenerate versioninfo.json and resource_windows.syso on each release
  • Sidebar nav item with SVG clipboard icon and live unread badge count (polled every 30s)

What changed

Backend

  • pkg/bulletins/ -- new package: Store, Sender, Scheduler, Service with full test coverage
  • pkg/configstore/models.go -- Bulletin model; migration 26 (bulletins table), 27 (bulletin_interval_mins on messages_preferences, kept for upgrade safety), 28 (interval_mins per-bulletin column)
  • pkg/messages/router.go -- BulletinSink interface and dispatch step
  • pkg/app/wiring.go -- wireBulletins, bulletinsComponent lifecycle
  • pkg/webapi/bulletins.go -- REST endpoints: GET/POST /api/bulletins, DELETE /{id}, POST /{id}/read, POST /read-all

Frontend

  • web/src/routes/Bulletins.svelte -- compose form (slot, text, interval), Received/Sent tabs, unread badge, send status per bulletin
  • web/src/routes/MessagesSettings.svelte -- Digipeater path field with station-level explanation
  • web/src/api/bulletins.js -- API client

Docs

  • docs/wiki/bulletins.md -- full subsystem wiki page: wire format, ingest/send flow, scheduler algorithm, DB schema, REST endpoints, wiring, frontend

Test plan

  • Send a bulletin from the Bulletins page; confirm it appears in the Sent tab and fires 3 times at 30s intervals then settles into the configured interval
  • Set interval to 0 and verify the bulletin shows Complete after 3 sends
  • Set interval to 10 and verify the send status shows "every 10 min"
  • Receive a bulletin from another station; verify unread badge appears in sidebar and Received tab
  • Mark a bulletin read; verify badge decrements
  • Delete an outbound bulletin; verify retransmits stop
  • Confirm digipeater path set in Messaging settings is used for both DMs and bulletins
  • Run on an RF-only (no iGate) station; confirm no error logs about IS not enabled

🤖 Generated with Claude Code

chrissnell and others added 30 commits May 19, 2026 08:27
The swagger regen (81174f7) added ChannelPtt.gpio_pin but the downstream
TypeScript client was not regenerated, so CI's api-client-check (a
go-test prerequisite, Makefile:134) would fail on this PR. Pure
generated-bindings sync; no behavior change.
…se-4b

fix(android): persist + restore channel PTT method (repairs AIOC PTT)
…75-tnc

Add working config documentation for TH-D75 KISS TNC on Pi 4
…y Pi

Adds goarch:arm goarm:6 to goreleaser, an arm-unknown-linux-gnueabihf
cross-rs target for the Rust modem, and a matching rust-build matrix
entry. One ARMv6 build covers Pi 1, Pi 2, Pi Zero and Zero W -- Pi 2's
Cortex-A7 is ARMv7 but runs ARMv6 binaries fine. No 32-bit ARM Docker
image; ships as .deb (armhf) and tarball (linux_armv6l) only.
…-build

build(release): add 32-bit ARM (armhf) Linux build for older Raspberry Pi
SendPacket enqueues onto the consumer goroutine, which persists and
then submits the auto-ACK sequentially. The existing waitFor on
store.List won the race in most runs, but the auto-ACK submit could
still be pending when sink.list() was read, producing flaky
auto-ACK count = 0 failures under -race in CI. Mirror the wait
pattern used in the broadcast test (line ~575).
…-method-field

feat(android): carry PTT transport in dedicated ptt_method field
Two related Android-focused design specs:

- 2026-05-19-android-bluetooth-kiss-tnc-design.md: BT-paired KISS TNCs
  (Mobilinkd, Kenwood TH-D74/D75, etc.) via a Kotlin BluetoothSocket
  relay over the existing platform UDS, feeding the unchanged
  pkg/kiss serial path through an injected OpenFunc. Bonded-only
  picker; no in-app pairing. Desktop unchanged (existing rfcomm bind
  workflow on Linux, /dev/cu.* on macOS).

- 2026-05-19-android-ptt-tab-design.md: undoes PR chrissnell#157's per-platform
  PTT divergence. One Ptt.svelte for both Android and desktop;
  per-channel PttConfig schema preserved (no migration); add/edit
  modal split into Change Method and Change Device dialogs; Android
  PTT block removed from ChannelEditModal.svelte.
…droid-bt-tnc-and-ptt-tab

docs(specs): Android Bluetooth KISS TNC + unified PTT page
… drop dead writeJob

handleSerialClose used to cancelAndJoin the read pump before closing the
socket. BluetoothSocket.inputStream.read() is a blocking native JNI call
that coroutine cancellation cannot interrupt -- only socket.close()
unblocks it via IOException. Swap the order to match closeQuietly: close
the socket first, then await the read job. Otherwise the launch hangs
and the socket never closes.

Test closeHandle_sendsClose_and_stopsPumps only verified that closing a
non-existent handle is a no-op (the comment in the test body admitted it).
Rename to closeNonexistentHandle_isNoOp so a future reader is not misled
into thinking live open/close lifecycle is covered.

HandleState.writeJob was declared, always constructed as null, and never
read anywhere. Drop the field and the forward-looking comment.
…vice

PlatformServer gains a nullable BtSerialAdapter field plus
attachBtAdapter() to wire it in after construction, and a typed
broadcastBt() that mirrors the existing broadcastGpsFix / broadcastGnssStatus
style. serveClient now dispatches SERIAL_OPEN, SERIAL_DATA, SERIAL_CLOSE,
and BONDED_BT_DEVICES_REQUEST directly to the adapter as fire-and-forget
notifications (replies travel back asynchronously via broadcastBt). A
missing adapter is treated as a no-op + DEBUG log so PlatformServerTest
keeps working without one.

GraywolfService constructs SystemBluetoothFacade + BtSerialAdapter after
PlatformServer.start() and attaches it. onDestroy calls
btSerialAdapter.shutdown() BEFORE platformServer.stop() so any final
SerialClose frames make it through the still-open UDS.
…ded-list refresh

Service-scoped BroadcastReceiver that watches BluetoothDevice.EXTRA_BOND_STATE
transitions. On BOND_NONE it tells BtSerialAdapter.onBondLost(mac) to tear
down any open RFCOMM handles for that device and push a refreshed bonded
list. On BOND_BONDED it just calls handleBondedRequest() so the Go side
sees a freshly paired device without the operator having to refresh.

Registration mirrors stopReceiver: RECEIVER_NOT_EXPORTED on API 33+,
unflagged registerReceiver below. Unregister in onDestroy wraps
IllegalArgumentException for idempotency. Receiving the broadcast does
not require BLUETOOTH_CONNECT (that permission gates direct API reads,
not intent reception), so this works on devices where the operator has
not yet granted the permission -- the bonded list will just be empty
until they do.
Adds a one-shot Bluetooth bonded-device query to the Go platformsvc
client. Wraps BondedBtDevicesRequest in roundTrip and registers
BondedBtDevicesResponse on the dispatch allow-list so the strict-ordered
respCh delivers the reply.

BondedBtDevice is the typed Go view of BondedBtDevicesResponse.Device
(MAC + Name). Order matches the platform's view at the moment of the
request; live bond changes flow through the bond-state broadcast added
in phase 2B, not this RPC.

Test: hermetic fake-server pair verifies a two-device response decodes
into the expected BondedBtDevice slice.
…teCloser

Adds an RFCOMM-SPP serial open path that returns a standard
io.ReadWriteCloser. Each open handle multiplexes onto the existing UDS
connection: a uint32 handle ID (atomic-allocated) keys per-stream
inbound channels, and the dispatch loop fans SerialOpenAck / SerialData
/ SerialClose / SerialError frames into the matching channel.

Reasons not to reuse roundTrip:
- BtSerialOpen waits for an ack but should not hold requestMu while
  the platform service is performing an RFCOMM connect (seconds, not
  milliseconds), which would block all other clients of the client.
- SerialData read/write traffic is fully asynchronous; the strict-
  ordered request/response model does not apply.

Per-handle delivery is non-blocking (256-frame buffer; dropped on
overflow). Write chunks the caller's bytes into 4 KiB SerialData frames;
Close is idempotent via sync.Once and emits a final SerialClose to the
server. SerialErrorErr surfaces out-of-band stream failures (bond_lost,
rfcomm_closed, etc.) distinct from io.EOF so callers can decide whether
to retry or surface to the operator.

writeMu serializes writeFrame calls so the new async writers (BtSerial
Write, Close, plus the cancel-emit in BtSerialOpen) don't interleave
their 4-byte length prefix with another writer's payload. roundTrip
now also takes writeMu for its single writeFrame call.

Tests (hermetic net.Pipe-driven fake server):
- TestBtSerialOpen_roundTrip: open + write + server-echo + read
- TestBtSerialOpen_serverClose_returnsEOF: server SerialClose -> io.EOF
- TestBtSerialOpen_serialError_returnsTypedError: SerialError surfaces
  as *SerialErrorErr with code and detail intact
…erialOpen cleanups

Without this, when the UDS dies or the client shuts down, every open
btReadWriteCloser.Read blocks forever on its per-handle channel because
handleDisconnect and Close only touched respCh and conn.

Add drainBtHandles() that snapshots and clears the btHandles map under
btHandlesMu, then closes each channel outside the lock (avoiding any
re-entry into dispatch paths that also take the mutex). Call it from
both handleDisconnect (after respCh close) and Close (after closeCh
close, before conn.Close). Safe when btHandles is nil (initial state).

Two new tests cover the fix:
- TestBtSerialOpen_clientClose_unblocksRead asserts Read returns io.EOF
  within 500 ms after the underlying client.Close().
- TestBtSerialOpen_serverDisconnect_unblocksRead asserts the same for
  the handleDisconnect path (server closes the pipe).

Minor cleanups in btserial.go:
- chunked Write uses the Go 1.21+ min() builtin instead of the manual
  bounds check.
- Read overflow path drops the unnecessary defensive copy into a fresh
  []byte; append() already copies data[n:] into its own backing array.

Minor cleanup in btserial_test.go: the btTestServer.stopCh field was
created and closed but never read -- the read loop exits because
s.conn.Close() causes readFrame to error. Delete the dead field and
its close() call.
…through platformsvc

- Expose kiss.OpenFunc as a type alias of SerialConfig.OpenFunc so
  out-of-package factories can return it without duplicating the
  signature.
- Add build-tagged factories in pkg/app:
    - kiss_openfunc_default.go: non-Android returns nil so the
      SerialSupervisor falls back to defaultSerialOpen.
    - kiss_openfunc_android.go: routes MAC-style device strings
      (colon or hyphen MAC-48) to platformsvc.BtSerialOpen and
      rejects raw device paths with errNotSupportedOnAndroid.
- Add App.kissSerialOpenFunc() accessor on both tags so wiring.go
  can call it tag-free.
- Unit tests cover MAC routing, non-MAC rejection, nil-client
  no-op, and the non-Android stub.
chrissnell and others added 30 commits June 10, 2026 22:55
…snell#218) (chrissnell#235)

* mapscatalog: actionable error for manifest auth failures (fixes chrissnell#218)

Map catalog warm-up surfaced the raw upstream body on a 401
("manifest HTTP 401: unauthorized: missing"), which gave operators
nothing to act on. Translate 401/403 into plain language that names
the cause (missing vs. invalid/expired maps access token) and points
at the fix -- (re-)register the device under Settings -> Maps. Other
non-200 statuses keep the status code and body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* mapscatalog: simpler, friendlier maps activation error wording

Per reviewer feedback, reword the 401/403 auth errors in the operator's
voice. No-token case now reads "To activate Graywolf Maps, go to the
Settings tab and register your device"; the rejected-token case mirrors
it with "re-register your device". Updated the tests accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus (Multica) <agent@multica.local>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
…fig (chrissnell#227) (chrissnell#242)

* fix(audio): pick native I16 playback format, never default_output_config (chrissnell#227)

cpal's default_output_config() on an ALSA plughw:/default PCM returns
F32. Opening an F32 *output* stream on a cheap USB radio codec
(Signalink, Digirig, AIOC) makes alsa::poll() return POLLERR every
period; the holding thread rebuilds with the same bad format and loops
forever, flooding "cpal output stream error: ... POLLERR" while TX
audio never reaches the rig. RX is unaffected because capture already
streams native I16.

This is the TX-side regression of the RX fix in f917b8f: that commit
taught spawn() to select the input format from the device's advertised
configs (pick_input_sample_format) but left spawn_output() calling
default_output_config().sample_format(), so the identical POLLERR loop
resurfaced on transmit only -- exactly issue chrissnell#227 (Fedora 44 + Pi 4,
Signalink + Digirig, RX fine / TX floods).

spawn_output() now selects the output SampleFormat from the device's
advertised supported configs at the chosen rate, preferring native I16
(pick_output_sample_format), falling back to the cpal default only when
the device advertises nothing usable. The input and output selectors
share native_format_rank (renamed from input_format_rank) so capture,
playback, and the detection probe cannot drift.

Co-authored-by: multica-agent <github@multica.ai>

* refactor(audio): single source of truth for native format selection

Code review flagged that pick_input_sample_format and
pick_output_sample_format had byte-identical bodies that could silently
drift -- the exact failure mode invariant 33 exists to prevent. Collapse
both onto a shared private pick_native_sample_format core; the two public
wrappers keep their direction-specific docs and call sites. No behavior
change; all audio::soundcard format tests pass.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: graywolf-agent <agent@graywolf.local>
Co-authored-by: multica-agent <github@multica.ai>
…ic path aliases (chrissnell#240)

* Fix over-counted hop count: exclude generic path aliases (chrissnell#222)

The map station view derived hop count from the number of H-bit
('*') path elements, counting consumed WIDEn-N/RELAY/TRACE aliases
as separate hops. A path like SHEPRD*,WIDE1*,ELY*,WIDE2* showed as
4 hops instead of 2 actual retransmissions (SHEPRD, ELY).

Introduce aprs.CountHops / aprs.IsGenericPathAlias as the single
source of truth for hop semantics and route stationcache hop
counting and the webapi last-digipeater lookup through it.

Co-authored-by: multica-agent <github@multica.ai>

* Exclude TCPIP/TCPXX IS-injection markers from hop count

Code review found that gated and third-party packets carry
TCPIP*/TCPXX* in the path with the H-bit set; these represent
the Internet, not an RF retransmission, so the map hop count was
still over-reported by one for IS-originated traffic. Add them to
IsGenericPathAlias alongside the existing aliases.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: graywolf-agent <agent@graywolf.local>
Co-authored-by: multica-agent <github@multica.ai>
…fixes chrissnell#225) (chrissnell#243)

* iGate: persist simulation-mode toggle so the UI reflects actual state

POST /api/igate/simulation only flipped the runtime atomic via
SetSimulationMode and never wrote configstore.IGateConfig. The
Simulation page reads simulation_mode from GET /api/igate/config on
load, so after Apply + refresh the toggle sprang back to its persisted
(false) value even though simulation was still running (visible as
"igate simulation send" log lines). The runtime toggle was also lost on
reload/restart, since buildIgateInstance re-seeds the atomic from the
stored config.

Route the toggle through a read-modify-write on the singleton iGate
config row before setting the runtime atomic, keeping the store the
single source of truth. Other config fields are preserved.

Fixes chrissnell#225

Co-authored-by: multica-agent <github@multica.ai>

* iGate: make simulation toggle a single-column update (review fix)

Code review flagged a lost-update race: persisting the simulation
toggle via a whole-row read-modify-write (UpsertIGateConfig with Save)
could silently revert sibling fields a concurrent PUT /api/igate/config
had just written, since the toggle wrote back a stale snapshot.

Replace the app-layer read-modify-write with Store.SetIGateSimulationMode,
which updates only the simulation_mode column on the singleton row in a
single transaction (creating the row if absent). This keeps the two
writers from clobbering each other's fields and removes the zeroed-row
edge case. Tests move to configstore and now assert sibling fields are
untouched and the no-row create path is correct.

Refs chrissnell#225

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: graywolf-agent <agent@graywolf.local>
Co-authored-by: multica-agent <github@multica.ai>
…hrissnell#244)

* Add wind barbs to live map weather station markers (chrissnell#215)

Replace the text-based wind speed/direction annotation on weather
station markers with standard meteorological wind barbs, rendered as
per-station inline SVG overlays.

- New wind-barbs map layer (web/src/lib/map/layers/wind-barbs.js)
  mirrors the existing stations/weather layer pattern: a DOM marker
  per station, kept in sync with the data store, with refresh /
  setVisible / setFilter / destroy.
- Pure, unit-tested glyph builder (wind-barb-glyph.js) encodes
  sustained wind speed in knots using WMO half barbs (5 kt), full
  barbs (10 kt) and pennants (50 kt); calm renders an open ring. The
  staff is oriented to the wind direction (degrees the wind blows
  from). Markers are inert so station clicks/hovers pass through.
- The weather chip now carries temperature only; wind is conveyed by
  the barb.
- New "Wind barbs" layer toggle (default on) alongside the existing
  weather overlays; honors the Direct RX filter like the other layers.

Co-authored-by: multica-agent <github@multica.ai>

* Wind barbs ride the Weather toggle, not a separate layer control

Per review: drop the dedicated 'Wind barbs' toggle from the layers
panel. The barbs are the weather wind display, so the existing Weather
overlay toggle now governs both the temperature chip and the barb.

Co-authored-by: multica-agent <github@multica.ai>

* Wind barbs: black stroke with white halo for legibility

The cyan barb washed out against the basemap. Switch to black strokes
with a white drop-shadow halo so it reads clearly over both light and
dark tiles.

Co-authored-by: multica-agent <github@multica.ai>

* Lift temperature chip clear of the wind barb when one is present

The black temperature chip floated directly above the station and
collided with a barb pointing upward. Raise the chip above the barb's
maximum reach (~49px) only when a real barb renders; calm/no-wind
stations keep the chip tucked close to the icon.

Adds quantizeKnots/hasWindBarb helpers (with tests) to the glyph module
so the weather layer can decide whether a barb sits beneath the chip.

Co-authored-by: multica-agent <github@multica.ai>

* Pin temperature chip below the callsign, right-justified to it

The floating temp marker drifted far from the station. Render the temp
inside the station marker instead: callsign and temp now stack in a
right-of-icon column with align-items:flex-end, so the temp sits just
below the callsign and right-justifies to its edge regardless of
callsign length.

The weather layer no longer owns a map marker -- it writes the temp into
a .wx-temp slot the stations layer exposes via getTempSlot(), keeping
units/visibility/Direct-RX logic in the weather layer and layout in the
station marker CSS. Drops the now-obsolete chip-lift logic and the
unused hasWindBarb helper.

Co-authored-by: multica-agent <github@multica.ai>

* Wind barbs: cache SVG ref + lock barb geometry conventions in tests

Address code-review nits: store the per-marker SVG element on the entry
instead of re-querying it on every wind change, and add tests asserting
the lone-half-barb tip inset and that calm is direction-invariant -- both
real conventions the fragment-count tests couldn't catch.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: graywolf-agent <agent@multica.local>
Co-authored-by: multica-agent <github@multica.ai>
…ggle (chrissnell#245)

* web(kiss): add TCP (server) KISS type to Android + local-only bind toggle

The on-device Go binary already implements the KISS TCP server; it was
only filtered out of the Android Type picklist. Add it back so an
on-device iGate client can dial graywolf over loopback (issue chrissnell#211).

Also add a "Local only" toggle for tcp (server) interfaces: checked
binds the listener to 127.0.0.1 instead of 0.0.0.0, keeping the KISS
port off the LAN -- the common case for a same-device iGate client. The
flag rides in the existing ListenAddr host (no schema change); the
response DTO derives it by loopback-detecting the stored host.

Co-authored-by: multica-agent <github@multica.ai>

* web(kiss): fix stale comment on Android default interface type

The openCreate() comment still claimed the Android Type menu omits tcp;
it now includes TCP (server), so the bluetooth default is a UX choice,
not a menu restriction. Comment only, no behavior change.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: graywolf-agent <noreply@graywolf.local>
Co-authored-by: multica-agent <github@multica.ai>
…ll#231) (chrissnell#246)

Post-t64 Raspberry Pi OS rebuilt libasound2 with 64-bit time_t, making
struct timespec 16 bytes. The Rust libc armhf target still defaults to
8-byte timespec, so cpal/alsa-rs get_htstamp() overwrites its stack
buffer during capture callbacks and crash-loops the modem.

Set RUST_LIBC_UNSTABLE_GNU_TIME_BITS=64 for both 32-bit armhf targets
(arm-unknown-linux-gnueabihf and armv7-unknown-linux-gnueabihf), so Rust's
timespec matches the system libasound2t64. Both link the same
libasound2t64:armhf and share the exposure. Scoped via a matrix conditional
plus per-target Cross.toml passthrough entries; aarch64 (already 64-bit
time_t) and all 64-bit/non-Linux targets are unaffected, and no pre-t64
32-bit ABI is produced.

Co-authored-by: graywolf-agent <agent@graywolf.local>
Co-authored-by: multica-agent <github@multica.ai>
* add object beacons

* commit

* commit 2

* Beacons: restore object-name placeholder example, add beaconLabel tests

Revert the object-name input placeholder back to the instructive
"e.g. FIELDDAY" example -- the field is an object name, not a callsign.

Add unit coverage for the new shared beaconLabel() helper: object_name
preference, per-row callsign override, station-callsign fallback, the
(unset) sentinel, and null-row tolerance.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Chris Snell <1072626+chrissnell@users.noreply.github.com>
Co-authored-by: multica-agent <github@multica.ai>
…ell#247)

Enrich the /api/packets DTO with channel_name, resolved from the numeric
channel ID against the configured channel inventory, and render it in the
PacketLogViewer (relabel the column Ch -> Channel). Falls back to the raw
ID when the channel maps to no configured channel (e.g. channel 0, used
for non-RF / APRS-IS arrivals).

Regenerates the OpenAPI spec and the frontend API types.

Co-authored-by: graywolf-agent <agent@multica.local>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
(cherry picked from commit 6a9b6e3)
Issue chrissnell#231: graywolf-modem SIGSEGV crash-loops on 32-bit Raspberry Pi OS
(Pi Zero/armv6) because libasound2t64 writes a 16-byte struct timespec into
alsa-rs's 8-byte get_htstamp buffer.

The shipped fix (RUST_LIBC_UNSTABLE_GNU_TIME_BITS=64) cannot link against our
Bionic (glibc 2.27) cross toolchain (undefined __ioctl_time64) and cascaded
into four crate forks. Replace it with a single alsa-rs patch that reads the
htstamp accessors into a 16-byte buffer, so the C call cannot overflow without
building for time64. The binary stays time32: it links on the current
toolchain and runs on both pre-t64 and t64 systems.

- Cargo.toml: [patch.crates-io] alsa -> fork with the 16-byte-buffer fix.
- release.yml / Cross.toml: remove RUST_LIBC_UNSTABLE_GNU_TIME_BITS.
- nix/cpal/gpiocdev stay stock (no forks); no time64 means they compile fine.

Design: docs/plans/2026-06-11-armhf-t64-alsa-htstamp-fix.md
Co-authored-by: multica-agent <github@multica.ai>
…#231)

Runs a C canary in a 32-bit ARM t64 userland under QEMU and asserts that an
8-byte get_htstamp buffer overflows while a 16-byte one does not -- the exact
premise the alsa-rs htstamp buffer fix relies on. Fails closed if the base
image is ever not a t64 32-bit userland.

Co-authored-by: multica-agent <github@multica.ai>
* Warn at startup when the system clock is not synced

An undisciplined clock skews packet ages and the map's Time Range
filter, which can silently hide stations (graywolf#234). Add a
pkg/clocksync adjtimex(2) check on Linux (Unknown/no-warn elsewhere)
and emit a one-time WARN from the startup banner.

Co-authored-by: multica-agent <github@multica.ai>

* clocksync: key sync state on STA_UNSYNC bit alone

Drop the redundant adjtimex return-state (TIME_ERROR) disjunct, which
could over-fire on leap-second/clock-error states. The kernel sets
STA_UNSYNC at boot and clears it only once a time source disciplines the
clock, so the bit alone covers the no-daemon and not-yet-converged cases
while leaving a synced clock with a pending leap second reported as
synced. Factor the decision into a pure classify() helper and table-test
the boundaries.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: graywolf-agent <agent@graywolf.local>
Co-authored-by: multica-agent <github@multica.ai>
…ell#253)

Resolve each packet's channel id to its operator-given name via the
shared channels store and emit it as a Channel column in packets.csv.
Falls back to the raw id when the channel was deleted. CSV cells are
now RFC 4180-quoted so channel names with commas or quotes can't
corrupt rows.

Closes chrissnell#233

Co-authored-by: graywolf-agent <agent@graywolf.local>
Co-authored-by: multica-agent <github@multica.ai>
…hrissnell#252)

* Scale dashboard uptime to days/weeks/months above thresholds

The Uptime stat rendered only as hours and minutes, which became hard
to read for long-running nodes. Scale the display to the largest
meaningful unit (days, weeks, months) once each threshold is met,
showing at most two units so the value stays on one line in the stat
card. Sub-day uptimes keep the existing hours/minutes format.

Closes chrissnell#248

Co-authored-by: multica-agent <github@multica.ai>

* dashboard: keep stat values on one line (nowrap)

Code review on chrissnell#248 flagged that long uptime strings (and existing
7-8 digit packet counts) could wrap at the minimum stat-card width
since .stat-value had no white-space rule. Add nowrap so the value
always stays on a single line.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Graywolf Agent <agent@multica.local>
Co-authored-by: multica-agent <github@multica.ai>
…r-path clear() on map teardown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- esc(): add double-quote escaping to close HTML attribute injection
  (affects existing data-callsign attrs too, so the fix is overdue)
- Message link: skip for APRS objects (is_object), which cannot receive
  APRS messages; use encodeURIComponent for the thread ID value
  (consistent with openDm() in Messages.svelte) and toUpperCase() to
  match the dm: thread key convention
- CSS: collapse stn-msg-link + stn-ext-link into shared stn-link class
- vite.config.js: correct dev proxy target to 127.0.0.1:8080 (matches
  the backend default in pkg/app/config.go; was pointing at 8081)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…elpers

Documents the child-before-parent Svelte onDestroy ordering and MapLibre
v5's map.remove() -> delete this.style behavior. Any layer helper with a
clear() or similar method called outside onDestroy must guard getSource()
with try/catch, same as destroy().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a full APRS bulletin-board subsystem: outbound compose/transmit
with per-slot retransmit scheduling per APRS101, inbound ingestion and
upsert via the message router, REST API, and a Bulletins UI page.

- pkg/bulletins: Store, Sender, Scheduler, Service with full test suite
  (store_test, service_test, scheduler_test).  Scheduler fires every
  20 min for BLN0-9 (max 12 sends) and every 1 h for BLNA-Z (max 96).
- Outbound packets use the operator's MessagePreferences.DefaultPath
  (WIDE1-1,WIDE2-1 default) so bulletins are digipeated like beacons.
  When an iGate is wired the bulletin is also sent directly to APRS-IS
  via TNC2/TCPIP* so it appears on aprs.fi; ErrNotEnabled is silently
  swallowed for RF-only operators.
- Inbound bulletins are routed by the message router to a BulletinSink
  interface; the router does not persist them as directed messages.
- SQLite partial-index upsert workaround: ON CONFLICT cannot target
  partial indexes, so UpsertInbound uses select-then-update/insert.
- REST: GET/POST /api/bulletins, DELETE /api/bulletins/{id},
  POST /api/bulletins/{id}/read, POST /api/bulletins/read-all.
  All handlers return 503 until the service is wired (safe startup).
- Frontend: Bulletins.svelte page with compose panel, slot selector
  (optgroup for bulletins vs announcements), 67-char counter, Received/
  Sent tabs with unread badge, mark-read, delete, 30-s auto-refresh.
  Sidebar icon uses inline SVG (chonky-ui allowlist does not include rss).
- Windows exe icon: goversioninfo embeds graywolf.ico into the binary
  via resource_windows.syso; bump-point and bump-minor now update
  versioninfo.json and regenerate the .syso on each release.
- Wiki: new bulletins.md; README and code-map updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bulletin scheduler: per APRS protocol the first sends after a bulletin
is created should fire rapidly to survive packet collisions, then settle
into the standard Net Cycle Time rate. Now sends 3 times at 30-second
intervals on creation, then switches to the 20-minute stable rate.
Poll interval reduced from 60s to 15s so the 30s burst windows are
actually caught. Added TestScheduler_BurstThenStableRate to verify the
transition from burst to stable interval.

Messaging settings: expose MessagePreferences.DefaultPath in the
Messaging settings page as a "Digipeater path" text field. This field
already existed in the DB and was used by both messages and bulletins
but had no UI. Operators can now set WIDE1-1, WIDE1-1,WIDE2-1, or
empty (direct) from the settings page. Added getter/setter to
messagesPreferencesState store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Slot taxonomy table now shows initial burst column (3x30s) vs stable
  interval (20 min) separately.
- Scheduler section corrected: poll is 15s not 20 min; burst-then-stable
  interval logic documented with the actual constants.
- Frontend section: note that rss icon is replaced by inline SVG; add
  note that digipeater path is now configurable in Messaging settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Operators can now set the stable bulletin retransmit interval from the
Messaging settings page. 0 = burst-only (3 sends at 30s, then stop);
1-20 = minutes between retransmits after the burst phase. Default is
20 min per the APRS Net Cycle Time spec for 2-hop stations.

- migration 27 adds bulletin_interval_mins to messages_preferences,
  backfills existing rows to 20
- Scheduler.intervalMins is an atomic uint32 updated at runtime via
  SetIntervalMins; burst-only clears NextSendAt so rows go dormant
- ServiceConfig.BulletinIntervalMins wired through app/wiring.go
- DTO Validate enforces 0-20 range; FromModel and buildPayload both
  include the field
- Frontend: Bulletins box in MessagesSettings.svelte with a 0-20
  number input; store getter/setter follow existing pattern
- Tests: BurstOnly_StopsAfterBurst and CustomInterval added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The retransmit interval is now set per-bulletin at compose time rather
than as a global Messages preference. Each bulletin carries its own
interval_mins (0=burst-only, 1-20=stable rate) so BLN0 can retransmit
every 20 min while BLN1 goes every 10 min, for example.

- migration 28 adds interval_mins to the bulletins table (SQL DEFAULT 20,
  backfills any pre-existing rows); the GORM struct tag intentionally
  omits the default so zero (burst-only) is stored as-is
- Scheduler reads b.IntervalMins per row; no more global intervalMins
  atomic on Scheduler, no SetIntervalMins/SetBulletinIntervalMins methods
- SendRequest carries IntervalMins; service.Send() stores it on the row;
  webapi handler passes it through from the DTO
- BulletinResponse now includes interval_mins for the UI to display
- Bulletins.svelte compose form: "Every N min" input (0-20, default 20),
  hidden for announcements which are always 1 hr; sendStatus() shows the
  per-bulletin rate
- Remove BulletinIntervalMins from MessagePreferences, messages DTO, store,
  and MessagesSettings.svelte Bulletins box

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- Burst-only bulletins (interval_mins=0) now set max_sends=BulletinBurstCount
  so the row exhausts naturally after 3 sends and the UI shows "Complete"
  instead of "3/12 sent * burst only" indefinitely
- Add TestSend_BurstOnly_MaxSends and TestSend_IntervalMins_Stored
- Add TestSendBulletin_IntervalMins_PassedThrough and _OutOfRange
- Fix TestSend_Valid to use IntervalMins:20 (zero value now means burst-only)

Frontend:
- Remove unused ALL_SLOTS constant from Bulletins.svelte
- Fix indentation on {#if !isAnnouncement} interval block
- MessagesSettings: strengthen digipeater path hint to explain it is a
  station-level setting covering all outbound APRS including bulletins

Wiki:
- Fix migrations heading: 26-27 -> 26-28
- Add interval_mins to DB column table with correct max_sends note
- Update POST /api/bulletins body to include interval_mins
- Update slot taxonomy: note stable interval is configurable (default 20 min)
- Explain global path rationale in the frontend section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upstream added migration 25 (beacon_send_path). Our branch migrations
shift from 25-28 to 26-29 accordingly:

  26: messages_retry_interval  (was 25)
  27: bulletins_table          (was 26)
  28: bulletin_interval        (was 27)
  29: bulletin_row_interval    (was 28)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upstream added invariants 48-50 (Direct RX filter, world slug, lost
connection indicator) while the branch had already added its own chrissnell#48
(MapLibre map.remove teardown guard). Resolved by keeping the branch's
chrissnell#48 and renumbering the upstream additions to chrissnell#49-51.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants