Skip to content

Add APRS bulletins#345

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

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

Conversation

@Russell-KV4S

@Russell-KV4S Russell-KV4S commented Jun 19, 2026

Copy link
Copy Markdown

Summary

  • Full APRS bulletin board: receive BLN0-9 bulletins and BLNA-Z announcements, display on a dedicated Bulletins page with unread badge, 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 (0-20 min, default 20; set to 0 for burst-only with no further retransmits)
  • Digipeater path for bulletins is the same station-level setting as directed messages (Messaging settings, default WIDE1-1,WIDE2-1); improved UI copy explains why it is a global setting
  • APRS-IS forward: bulletins are also forwarded directly via TNC2 format when an iGate sender is wired; ErrNotEnabled is silently swallowed for RF-only stations
  • Adds configurable DM retry settings to the Messaging Settings page: max retries (default 4, range 1-20) and retry interval in seconds (default 30, range 10-120). Previously these were hardcoded constants.
  • Windows exe icon embedded via goversioninfo; Makefile bump targets regenerate versioninfo.json and resource_windows.syso on each release

What changed

Backend

  • pkg/bulletins/ -- new package: Store, Sender, Scheduler, Service with full test coverage
  • pkg/configstore/models.go -- Bulletin model; four migrations (all post-AutoMigrate, raw SQL):
    • 26: adds retry_interval_secs to messages_preferences
    • 27: creates bulletins table with partial unique index on (from_call, slot)
    • 28: adds bulletin_interval_mins to messages_preferences -- orphaned; design moved to per-row column in migration 29 (noted in wiki; no Go code reads it)
    • 29: adds interval_mins per-row on bulletins table
  • pkg/messages/router.go -- BulletinSink interface and dispatch step; inbound BLN* packets route to the bulletin service rather than being dropped as not_for_us
  • pkg/messages/retry.go -- RetryIntervalSecs and RetryMaxAttempts now read live from MessagePreferences; changes take effect without restart
  • 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, per-row send status and delete
  • web/src/routes/MessagesSettings.svelte -- Retry box (max retries + interval seconds); digipeater path field with station-level explanation
  • web/src/api/bulletins.js -- API client

Docs

  • docs/wiki/bulletins.md -- subsystem wiki page: wire format, slot taxonomy, ingest flow, outbound send topology, migration notes (including migration 28 dead-column warning), app wiring, frontend wiring. Internal scheduler logic and full schema table removed per wiki conventions (component-internal detail belongs in code).
  • docs/wiki/code-map.md -- bulletins entry (corrected migration 26->27), messages retry settings added to messages entry, configstore entry notes migrations 26-29
  • docs/wiki/README.md -- updated bulletins.md description

Known issues (flagged in review, not blocking)

  • Migration 28 dead column: bulletin_interval_mins was added to messages_preferences but no Go code reads it. Design was superseded by per-row interval_mins on the bulletins table (migration 29). Flagged in wiki. Cleanup migration can follow separately.
  • Double-send on transient DB error (scheduler.go): SendCount is incremented in memory before store.Update persists it. A SQLite write failure leaves the row re-eligible on the next scheduler tick, potentially causing an extra RF send.
  • SetTxChannel dormant data race: plain uint32 field written without atomic; no call site exists post-Start today but inconsistent with the messages.Sender pattern (atomic.Uint32).
  • parsePath duplication: copied verbatim from pkg/messages/sender.go; candidate for extraction to a shared package.

Test plan

  • Send a BLN0 bulletin; confirm it appears in Sent tab and fires 3 times at 30s intervals then settles to the configured interval
  • Set interval to 0 (burst-only); verify the bulletin shows Complete after 3 sends with no further retransmits
  • Receive a bulletin from another station (or via iGate IS feed); verify unread badge in sidebar and Received tab
  • Mark all read; confirm badge clears
  • Delete an outbound bulletin; confirm retransmits stop immediately
  • Change retry interval and max retries in Messaging Settings; confirm values persist after reload and affect DM retry behavior
  • Confirm digipeater path set in Messaging settings is used for both bulletins and directed messages
  • Run on RF-only station (no iGate); confirm no error logs about IS not enabled

🤖 Generated with Claude Code

chrissnell added 30 commits May 19, 2026 16:41
…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.
…ith OpenFunc

The KISS dispatch loop previously skipped the bluetooth interface
type. Add an explicit KissTypeBluetooth case that hands the MAC
(stored in ki.Device) to Manager.StartSerial with BaudRate=0 and
Mode=ModeTnc forced regardless of DTO input -- RFCOMM has no baud
and Bluetooth SPP TNCs always own the modem.

Wire OpenFunc on both the serial and bluetooth cases via the new
App.kissSerialOpenFunc() accessor:
- Desktop: returns nil so the supervisor falls back to its default
  go.bug.st/serial opener.
- Android: returns an OpenFunc that routes MAC-style device strings
  through platformsvc.BtSerialOpen and rejects raw device paths.

Same SerialSupervisor implementation serves both transports.
Add TestSerialSupervisor_BluetoothShapedConfig_RxRoundTrip which
drives the supervisor with the exact shape pkg/app/wiring.go uses
for KissTypeBluetooth: MAC-style device string, BaudRate=0, and
Mode=ModeTnc. The injected OpenFunc returns a net.Pipe end the
way platformsvc.BtSerialOpen returns an RFCOMM-multiplexed
io.ReadWriteCloser on Android; the supervisor doesn't care which
is which.

Asserts that:
- OpenFunc receives the MAC and a zero baud (RFCOMM has no baud).
- A KISS frame written to the pipe round-trips through ServeTransport.
- OnFrameIngress fires with ModeTnc (BT TNCs own the modem).

This is the integration proof that the new dispatch case in
wiring.go wires the BT path through unchanged supervisor code.
…no baud)

The original predicate rejected BaudRate==0 for both KissTypeSerial and
KissTypeBluetooth, but wiring.go hardcodes BaudRate=0 on the bluetooth
path because RFCOMM has no baud rate. Net effect was that valid POST/PUT
requests for bluetooth KISS interfaces were rejected at the API boundary.

Split the predicate: require BaudRate>0 only for real serial devices.
Bluetooth requests with BaudRate==0 are now accepted. Updated the
existing baud-rate test to assert the new bluetooth-accepted case and
the corrected error message ("serial interfaces" instead of
"serial/bluetooth interfaces").
Adds the bonded-Bluetooth-devices REST endpoint that the Svelte UI's
Bluetooth KISS form (Phase 5) reads to populate the bonded-device
dropdown. On Android the handler enumerates bonds through the
platformsvc client (via a build-tagged App.btSourceForWebapi adapter);
on desktop builds the source is nil and the handler returns 501.

Wire shape: GET returns {"devices": [{"mac": "...", "name": "..."}]}.
Empty bond sets serialize as [], never null, so the UI's iteration code
doesn't need to special-case null.
…nt on btsource adapter

The 501 (non-Android) and 500 (source error) responses from
GET /api/kiss/bonded-bt-devices used http.Error, producing
text/plain bodies that contradicted the Swagger doc-comment
(which declared webtypes.ErrorResponse for both). The 500 path
also echoed the raw underlying error string to the client,
risking leakage of driver/stack details the way the rest of the
webapi explicitly avoids (see pkg/webapi/errors.go:48-51).

Switch both paths to the in-tree convention:
  - 501 -> writeJSON(w, http.StatusNotImplemented, webtypes.ErrorResponse{...})
    (matches the precedent at pkg/webapi/ptt.go:243-244)
  - 500 -> s.internalError(w, r, op, err)
    (logs the real error server-side, returns sanitized JSON)

Update TestGetBondedBtDevices_SourceError_Returns500 to drop the
old assertion that the response body contained the raw 'boom'
text (internalError deliberately strips it) and add positive
assertions: application/json content-type, JSON-shaped
webtypes.ErrorResponse body with a non-empty Error field, and a
regression guard that the raw underlying error is NOT echoed.
Extend TestGetBondedBtDevices_NonAndroid_Returns501 with the same
JSON-shape checks so the 501 body contract is also pinned.

Also reword the btsource_android.go header comment, which
claimed the per-call platformClient read matched 'the pattern
used by the other webapi setters wired in pkg/app/wiring.go'.
That's not true -- kissSerialOpenFunc in
kiss_openfunc_android.go captures the client once at
construction. The honest justification is that per-call keeps
the door open for a future late SetPlatformClient or
reconnect-swap without surprising the UI's polling loop, at
one extra pointer load per request.
chrissnell and others added 22 commits June 11, 2026 00:05
…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>
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>
@Russell-KV4S
Russell-KV4S force-pushed the feature/aprs-bulletin-board branch from e1e9e4b to a7ffad8 Compare June 25, 2026 14:10
Russell Thomas and others added 6 commits June 26, 2026 10:31
- code-map: add messages RetryIntervalSecs/RetryMaxAttempts to messages entry
  (migration 26 adds retry_interval_secs; 0 stored = use default 30s/4
  attempts; settable in MessagesSettings Retry box)
- code-map: fix bulletins table migration number 26 -> 27
- code-map: note migration 28 (bulletin_interval_mins on messages_preferences)
  is a dead column superseded by migration 29's per-row interval_mins
- code-map: add migration 26-29 summary to configstore entry
- bulletins.md: replace step-by-step scheduler internals with topology summary
- bulletins.md: replace full column table with migration note (component-
  internal detail belongs in the code per CLAUDE.md)
- bulletins.md: replace REST endpoint table with pointer (REST reference
  belongs in the handbook per CLAUDE.md)
- bulletins.md: fix section header "migrations 26-28" -> "migrations 27, 29"
- bulletins.md: add dead-column warning for migration 28
- README.md: update bulletins.md description to match current content

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- scheduler: announcement interval (BLNA uses 1h, not burst/stable)
- scheduler: soft-deleted row is not re-sent after kick
- webapi/bulletins: verify direction+unread_only query params are
  propagated to the service Filter (existing test ignored the filter)
- messages/router: bulletin sink error drops packet gracefully and
  router continues processing subsequent packets without panic

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Upstream added invariants 53 (map-viewport any-trail-position), 54
(stationcache trail order/dedup), and 55 (SmartBeaconing tracker gate).
Our fork had a conflicting invariant 53 (MapLibre map.remove teardown
guard) -- renumbered to 56.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n, .syso)

build-pipelines.md: add a row for resource_windows.syso explaining that
goversioninfo generates it from versioninfo.json + graywolf.ico, that the
.syso is committed so plain go build works anywhere, and how to regenerate
it manually.

code-map.md: add a "Go binary entry point (cmd/graywolf/)" section covering
main.go, versioninfo.json, graywolf.ico, resource_windows.syso, authcli/,
parentwatch.go, android_config.go, and flare.go. Consolidates the flare
entry point row (previously floating in the flare CLI section) into the
entry-point table.

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

# Conflicts:
#	docs/wiki/invariants.md
#	pkg/configstore/migrate.go
#	pkg/configstore/models.go
#	pkg/messages/service.go
#	pkg/webapi/server.go
#	web/src/routes/MessagesSettings.svelte
@Russell-KV4S Russell-KV4S changed the title Add APRS bulletin board Add APRS bulletins Jul 20, 2026
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