Add APRS bulletin board#345
Open
Russell-KV4S wants to merge 1187 commits into
Open
Conversation
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
…egacy gpio_pin carrier
…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.
…gurePtt; drop gpio_pin android carrier
…-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.
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
What changed
Backend
pkg/bulletins/-- new package: Store, Sender, Scheduler, Service with full test coveragepkg/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 steppkg/app/wiring.go-- wireBulletins, bulletinsComponent lifecyclepkg/webapi/bulletins.go-- REST endpoints: GET/POST /api/bulletins, DELETE /{id}, POST /{id}/read, POST /read-allFrontend
web/src/routes/Bulletins.svelte-- compose form (slot, text, interval), Received/Sent tabs, unread badge, send status per bulletinweb/src/routes/MessagesSettings.svelte-- Digipeater path field with station-level explanationweb/src/api/bulletins.js-- API clientDocs
docs/wiki/bulletins.md-- full subsystem wiki page: wire format, ingest/send flow, scheduler algorithm, DB schema, REST endpoints, wiring, frontendTest plan
🤖 Generated with Claude Code