Commit graph

401 commits

Author SHA1 Message Date
Matt Johnson
e7b7bd2025 feat(meshcore): decouple per-mesh LLM DM gate + mesh-scoped chat context
- router.should_respond branches on message.transport: MeshCore DMs are
  governed solely by meshcore_context.respond_to_dms (enforced at the
  transport); Meshtastic solely by bot.respond_to_dms. No global gate.
- MeshObservation tagged per-transport; the LLM "recent traffic" block is
  scoped to the originating mesh (keyword override for the other mesh),
  labeled by mesh so the model knows which it is describing.
- MeshCore observe_channels is now opt-in (empty = observe none).
- Chat-context retention 30d -> 14d (both meshes).
- Meshtastic integer channel-index filter no longer misapplied to MeshCore
  observations (their channel is a companion slot index).
- Frontend: relabel DM toggles per-mesh ("Answer direct messages",
  Meshtastic-only / MeshCore-only), remove the false channel-mention
  tooltips, opt-in wording for MeshCore observe-channels.

Tests: +tests/test_llm_scoping.py (10), context-filter updated for opt-in;
0 new failures (34 pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:39:07 +00:00
284fb5cbf2
MeshCore Contacts roster + Companion status (read-only) (#17)
* feat(dashboard): MeshCore Contacts roster + Companion status (read-only)

Expose the live companion's contact roster (get_contacts) and self/channel
status via /api/meshcore/contacts + /api/meshcore/self. Fill the Contacts
(roster table) and Companion (status + channels) pages. Telemetry auto-poll
comes next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(meshcore): self-advertisement (send-advert + advert-on-connect + periodic)

AIDA now announces itself: send_advert(flood=True) on every connect, an
optional periodic auto-advert (meshcore_advert_interval_seconds), and a
manual "Send Advert" button + POST /api/meshcore/advert. Makes the
companion discoverable/DM-able on the mesh.

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:14:10 -06:00
61ca0057e2
fix(nws): tighten hazard wording across all product types; kill dangling '— …' in L4 (#16)
Special Weather Statements (and other SPS/WSW/FFW/FLW products) rendered a
verbose raw hazard sentence on L3 that ate the packet budget, collapsing L4 to
a dangling 'Moving SW 24 mph —…' with every town lost (seen live in the
Activity Log).

- Add _tighten_hazard(): compacts free-form NWS hazard text into the terse SVR
  idiom for ALL branches — 'Wind gusts in excess of 45 mph' -> '45mph gusts',
  'in excess of' -> '>', '45 mph' -> '45mph', 'pea size hail' -> '0.25" hail'.
  Applied to the FFW/FLW and SPS/WSW/else branches (SVR already terse).
- Rework L4 assembly to be budget-aware BEFORE the final hard cap: try location
  forms richest->poorest (full list -> first->mid->last -> first->last ->
  first-only -> none) and only attach '— {locs}' when the whole message fits.
  If no location fits, degrade to motion-only; if even that overflows, drop L4.
  A dangling '— …' / trailing '—' is now structurally impossible.
- Tests: SPS worst-case (tightened + no dangling), WSW, pathological
  motion-only degrade, SVR no-dangling re-verify; shared dangling-separator
  assertion.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 15:22:07 -06:00
0460462485
Phase A — 4-section nav; move Scheduled Broadcasts + Danger Zones off Routing (#15)
* feat(dashboard): Phase A — 4-section nav; move Scheduled Broadcasts + Danger Zones off Routing

Regroup nav into GENERAL/MESHTASTIC/MESHCORE/DOCUMENTATION (<=5 pages each,
MT & MC mirror). Consolidate via tabs (Places, Nodes & Health, Contacts &
Companion) reusing existing components. Move Band Conditions, cold-start,
and fire digest to per-mesh Scheduled Broadcasts pages; move Danger Zones
to its own page. Routing keeps its sending rules unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase B — clean identical Routing grids; relocate per-family gating to Data Feeds

Meshtastic Routing becomes an always-visible pure-delivery grid matching
MeshCore (no master-toggle expand/collapse). Per-family gating (enable/
severity/freshness/cooldown) moves to a Family Settings section on Data
Feeds. Sending rules + Notification Rules unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase C — MeshCore bot-behavior parity (observe channels / ignore contacts / DMs)

Add meshcore context (observe channels by name, ignore contacts, DM policy)
and wire the MeshCore inbound path to honor it, mirroring Meshtastic's
observe/ignore filtering. Symmetric "Bot behavior" sections on both
Connection pages. Meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase D — dedupe Environment/Adapter Config into one Data Feeds surface

Curated family panels are the single home for the shared adapter keys;
Adapter Config becomes an Advanced/raw escape hatch (owned keys no longer
double-editable). Surface include_in_llm_context per adapter. Fix the
adapter-config array-vs-object parsing (fire digest values now load).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): Phase E — Activity Log (per-mesh broadcast feed); remove subscription backend

Replace Alerts with an Activity Log fed by per-mesh broadcast logging
(transport+channel+success on mesh_broadcasts_out, additive migration).
Remove the entire subscription backend (commands, DM dispatch, storage,
API) and its UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:57:24 -06:00
11bac716d0
feat(dashboard): send-test-message + MeshCore channel list (#14)
Add POST /api/mesh/test-send (fire a labeled test broadcast on a chosen
mesh+channel via the live transport) and GET /api/meshcore/channels
(surface the companion's enumerated channel names). "Send test message"
cards on both Connection pages, with the MeshCore one listing real
channels. Lets the operator confirm a mesh's send path on demand.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:06:31 -06:00
47b56adab5
refactor(transport): derive active transports from config, drop transport setting (#13)
* refactor(transport): derive active transports from config, drop transport setting

A mesh is active when its connection is configured: Meshtastic is the
always-on base; MeshCore runs whenever meshcore_host is set (blank = off);
both configured = both. Removes the transport mode field/toggle entirely
so there's no separate flag to miss.

* docs: fix stale transport comment after field removal

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

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 00:18:53 -06:00
b260dcbae0
fix(dashboard): restart URL, Enable-MeshCore toggle, unsaved-changes guard (#12)
* fix(dashboard): restart URL, Enable-MeshCore toggle, unsaved-changes guard

- RestartBanner POSTs /api/restart (was /api/system/restart -> 405)
- MeshCore Connection: replace transport-mode dropdown with an
  Enable MeshCore toggle (on=both, off=meshtastic)
- Guard unsaved edits: confirm before navigating away with pending
  changes (config pages no longer silently discard edits)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(dashboard): drop stale "Transport mode" wording on MeshCore Connection header

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:41:56 -06:00
de1e58aa71
feat(routing): MeshCore as first-class delivery types (meshcore_broadcast/dm) (#11)
* feat(routing): MeshCore as first-class delivery types (meshcore_broadcast/dm)

Replace the composite auto-fan with explicit per-mesh delivery types so
each family independently controls broadcast/DM per severity on Meshtastic
AND MeshCore. mesh_broadcast->Meshtastic only, meshcore_broadcast->MeshCore
(by channel name), mesh_dm/meshcore_dm likewise; routing via the existing
transport hint. Adds meshcore_dm_contacts. Meshtastic-only configs
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routing): deliver meshcore_broadcast via meshcore_channel through CompositeTransport

The hinted _broadcast path passed the channel NAME on the `channel` kwarg,
which MeshCoreTransport ignores (it reads meshcore_channel), so
meshcore_broadcast silently no-op'd on transport=both configs. Route the
meshcore child via meshcore_channel and the meshtastic child via channel.
Fix the test that asserted the broken kwarg layout. Add the new delivery
types to the remaining enumeration/validation sites (channel-test endpoint,
scheduler digest chunking, danger-zone valid set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): split Notifications into Meshtastic and MeshCore sections

Delineate per-mesh routing: each family configures Meshtastic delivery
(mesh_broadcast/mesh_dm, channel index, node IDs) and MeshCore delivery
(meshcore_broadcast/meshcore_dm, channel name, contacts) in separate
sections; shared settings (enable/severity/regions/email/webhook) once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): first-class MeshCore nav section + dedicated pages

Group the sidebar into Meshtastic and MeshCore sections. Promote MeshCore
routing and connection to their own pages; move MeshCore routing out of
Notifications (which stays Meshtastic + shared family settings). Add
placeholder Contacts and Companion pages for the follow-on companion data
API. No backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): parallel MT/MC nav order + symmetric connection links

Order both nav groups Connection/Routing/Mesh(Contacts)/Sources(Companion).
Replace the prominent MeshCore block on the Meshtastic Connection page with
a single subtle cross-link, mirrored on the MeshCore Connection page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboard): focused Meshtastic Connection/Sources pages for MT/MC parity

Meshtastic Connection and Sources are now their own focused pages
(mirroring MeshCore), instead of deep-linking into the full Config page.
Global settings move to a restored top-level Config item. No duplicate
editors; connection cross-links are mirror-image between the two pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:42:14 -06:00
24cb6a31df
feat(dashboard): MeshCore transport + per-family routing GUI controls (#10)
* feat(dashboard): MeshCore transport + per-family routing GUI controls

Add Transport mode selector (Meshtastic/MeshCore/Both) and MeshCore
host/port fields to the Config Connection section, and an independent
per-family "MeshCore channel" number input in Notifications (blank = not
broadcast on MeshCore, sends null). Extends the ConnectionConfig and
per-family toggle TS types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routing): MeshCore routing by channel name, not index

MeshCore channels are {name,PSK} (up to 40+ slots, not Meshtastic's 0-7).
The send index is a fragile slot position, so store the channel NAME per
family and resolve name->slot against the companion's live channel table
at send time; never blind-send to an unresolved slot. GUI field becomes a
channel-name text box. meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routing): thread per-family meshcore_channel through the broadcast send path

MeshBroadcastChannel now carries the rule's meshcore_channel name and
passes it to send_message, so per-family MeshCore routing actually fires
end-to-end (dispatcher -> channel -> composite -> MeshCoreTransport).
Meshtastic path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:49:59 -06:00
6bc57709a2
feat(broadcast): fit all mesh message formats to the packet budget (#8)
Tighten broadcast formats to fit 140 chars with critical info preserved:
traffic (directions/milepost never abbreviated, narrative trimmed from
end), nws hazard wording tightened (towns already path-sampled), fires
(drop ID line + ** + discovery time), avy (advice -> first sentence),
satpass/quake safety cap. Fire digest broadcast disabled by default.
All budget-aware via the shared max_chars.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:49:33 -06:00
7c15fa3f09
feat(reply): cap interactive LLM replies to 3 mesh packets (#7)
Add terse-answer guidance to the interactive system prompt and a hard
ceiling of 3 packets (3 x connector.max_chars) on LLM replies, with an
"ask for more" indicator when truncated. Protects LoRa airtime from
runaway replies. Broadcast chunking unchanged.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:49:10 -06:00
ff3ded8ca2
feat(routing): independent per-family MeshCore channel (#9)
Add meshcore_channel (Optional, default None) to each notification family
toggle, routed independently of the Meshtastic broadcast_channel. On a
broadcast the Meshtastic child uses broadcast_channel and the MeshCore
child uses meshcore_channel; an unset meshcore_channel means the family
does NOT broadcast on MeshCore (no default, no parallel to Meshtastic).
Additive; Meshtastic-only behavior unchanged.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:47:52 -06:00
f3df7a0a6d
feat(transport): CompositeTransport for dual Meshtastic+MeshCore (Phase 4) (#6)
* feat(transport): CompositeTransport for dual Meshtastic+MeshCore (Phase 4)

Adds CompositeTransport (transport: both) that fans broadcasts to both
meshes, sizes to min(children) for uniform messages, and routes DM
replies back over the originating mesh via a transport hint threaded from
the inbound MeshMessage. Per-child self-filtering; supervisor watchdog now
resolves the Meshtastic child inside the composite. Additive/optional
throughout; single-transport behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sizing): fixed universal mesh budget (mesh_max_chars=140)

Replace per-transport / min-of-active max_chars with a single fixed
universal constant (mesh_max_chars, default 140 = MeshCore LCD). Every
message is built once against one deterministic budget regardless of
which radios are connected; no runtime variance, no per-transport
retooling. Meshtastic sizing intentionally moves 200 -> 140.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 12:33:17 -06:00
225a5d37df
refactor(sizing): single mesh packet budget via transport.max_chars (Phase 3) (#5)
Route all mesh message sizing (renderer, digest, reply chunker, NWS
one-packet fit) through the active transport's max_chars instead of
scattered literal 200s. Meshtastic pinned at 200 (byte-identical output);
MeshCore uses its configured ~140. Sets up uniform-to-smaller sizing for
the composite transport. No behavior change on the Meshtastic path.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:46:11 -06:00
316ae7351e
feat(transport): MeshCoreTransport over pyMC companion TCP (Phase 2) (#4)
* feat(transport): MeshCoreTransport over pyMC companion TCP (Phase 2)

Implements MeshCoreTransport (MeshTransport impl) using the meshcore lib
over TCP to a pyMC companion frame server, bridged behind the sync
interface via a dedicated event-loop thread. Outbound channel/DM sends,
inbound message normalization into MeshMessage(transport="meshcore"),
contact/self lookups. Factory wires transport="meshcore"; supervisor is
now transport-aware (Meshtastic watchdog guarded). Dormant unless
configured; meshtastic path unchanged; full suite matches baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(transport): sync loop-thread readiness before dispatch (MeshCore)

Wait on a threading.Event set from inside the event loop (via call_soon)
before dispatching the first coroutine in connect(), eliminating a startup
race where run_coroutine_threadsafe could be rejected by an is_running()
pre-check before run_forever() had begun spinning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(transport): MeshCore broadcasts use configured channel index

The channel arg carries Meshtastic-index semantics that don't map to
MeshCore's channel table; broadcasts now always use the configured
meshcore_channel_index (also fixes explicit channel=0 being treated as
falsy). DM path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 10:15:39 -06:00
61278ece28
refactor(transport): introduce MeshTransport abstraction (Phase 1) (#3)
Behavior-preserving seam for a future MeshCore transport. Adds a
MeshTransport ABC + factory; renames MeshConnector -> MeshtasticTransport
(with a back-compat alias); generalizes MeshMessage additively (transport
tag, optional packet); adds a `transport` config field defaulting to
"meshtastic". No runtime behavior change; full suite matches baseline.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:33:50 -06:00
e15823ef84
feat(persistence): v19 schema — fire enrichment columns (#2)
Add nullable fire_cause, unique_fire_id, geocoder_city columns to the
fires table (additive migration, no backfill) and bump SCHEMA_VERSION
18 -> 19. Validated in production on CT 108 (schema_meta.version=19,
clean run); commits the finished fire-spam/danger-zones migration that
was live but uncommitted.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:44:32 -06:00
3c3ad3f42e
fix(nws): keep weather alerts in one packet without dropping storm path (#1)
Weather alerts ran ~250-310 chars and were blind-sliced to 200 in the
central consumer, silently dropping storm motion + the impacted-town
list. The town list was also pre-capped to 80 chars at parse time,
destroying the middle/end of the storm path before formatting.

- nws_handler: preserve the full impacted-town list; when the message
  overflows one mesh packet, sample the path (first -> middle -> last)
  instead of truncating the tail, so both path endpoints survive
- consumer: pass precomposed titles through verbatim (no [:200] chop)
- adapter_config: add nws.single_packet_max_chars (default 200)
- tests: path-sampling + short-list coverage

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:14:26 -06:00
e6bc101194 fix(nws): normalize severities to schema; drop CAP pre-filter
Remove the nws_handler broadcast_severities/warning_suffix_promotes
pre-filter (GATE A) that dropped sub-Severe NWS products before the
pipeline. All NWS alerts now normalize to routine/priority/immediate
(map_severity) and breadth is governed solely by the per-toggle
dispatcher threshold. Warning-class categories are promoted to
immediate so a wrong/missing CAP severity int cant under-rank a real
warning. broadcast_severities/warning_suffix_promotes are now inert
(marked deprecated). Fixes sub-Severe alerts (Special Weather
Statements / advisories) for Magic Valley / East Idaho never reaching
the mesh despite a routine toggle threshold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 00:12:27 +00:00
Ubuntu
e982cbbdf6 refactor(danger-zones): drop min_severity + position_max_age; table snow (grayed/inert)
- alert on any hazard touching a node (severity gate removed)
- alert regardless of position staleness; only skip nodes with no position
- snow tabled: grayed-out in GUI + skipped in correlator pending snowfall+elevation pipeline

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:56:22 +00:00
Ubuntu
95b1a23ef1 feat(danger-zones): configurable infra-node hazard correlation + fire age-gate
- danger_zones config section (isolated dataclass + danger_zones.yaml + GUI panel on Notifications page); defaults disabled + dry_run
- DangerZoneCorrelator: distance-based correlation of hazard events vs infra nodes (CLIENT_BASE/ROUTER/ROUTER_LATE), DM delivery, cooldown
- fire age-gate (wfigs max_declare_age_seconds) suppresses stale/closed fires announced as "New"
- tests: correlator + fire-gate boundary; wfigs fixture fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:33:54 +00:00
Matt Johnson
b6e15f656f chore: capture in-flight fire-spam/drain-pacer + reconnect ground truth from CT108
Preserve the live CT108 working-tree state (the running container is already
built and is unaffected by this commit).

Canonical build tree (work/ — what ships):
  - work/meshai/config.py      : ConnectionConfig watchdog reconnect knobs
  - work/meshai/connector.py   : watchdog link-state, socket-based liveness,
                                 active_probe, in-place reconnect
  - work/meshai/main.py        : connection supervisor (watchdog) task
  - work/Dockerfile            : healthcheck also asserts /tmp/meshai.link=up
  - work/docker-compose.yml    : matching healthcheck change

Root tree (stale duplicate, earlier reconnect iteration — preserved for fidelity):
  - meshai/config.py, meshai/connector.py, meshai/main.py
  - Dockerfile, docker-compose.yml

New (root only, NOT under work/, currently unimported / not in build):
  - meshai/notifications/pipeline/severity_router.py

Excluded: all *.bak / *.bak2 backup artifacts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 05:58:23 +00:00
Ubuntu
6ed8ad4945 fix(satpass): add date label and single-observer region to wire format
Passes not happening today now show "tomorrow" or "Mon Jun 17" so they
aren't mistaken for past events. Single-observer consolidations now show
the observer name as region context, e.g. "(Treasure Valley)".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-16 15:01:20 +00:00
Ubuntu
6e3eae39fb fix(ci): add package-lock.json for npm ci in Docker build
npm ci requires a lockfile. Generated from existing package.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-16 03:48:44 +00:00
Ubuntu
107f5435b2 fix(ci): add README.md to work/ for Docker build context
pyproject.toml references readme = "README.md" and the Dockerfile
COPYs it for pip install -e. With build context set to work/, the
file must exist there.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-16 03:46:39 +00:00
Ubuntu
2e1fb325f7 refactor: move source tree into work/, multi-stage Docker build, fix satpass
- Move all application source (meshai/, dashboard-frontend/, tests/,
  config/, docs/, Dockerfile, etc.) into work/ directory
- Add Node.js multi-stage build to Dockerfile for frontend compilation;
  remove compiled static assets from git tracking
- Fix satpass missing time windows: consolidation was splitting wire on
  newline and only putting line 1 in event.title, dropping the time
  window line that the composer uses for precomposed broadcasts
- Fix satpass burst flooding: stagger consolidation timers (+60s per
  pending pass) so Central batch publishes don't blast the mesh
- Update CI workflow build context to work/
- Anchor lib/ and data/ gitignore patterns to repo root to prevent
  false matches on nested directories
- Add dashboard-frontend/node_modules/ to .dockerignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-16 03:40:31 +00:00
Ubuntu
7128b432ee fix(satpass): add AOS horizon guard to reject stale far-future passes
Passes whose AOS is more than max_aos_horizon_hours (default 24) in the
future are now rejected in the handler. Prevents stale predictions
republished via NATS LAST_PER_SUBJECT from broadcasting passes that are
too far out to be actionable. Complements the existing los < now guard
which catches passes that already ended.

New adapter_config key: satpass.max_aos_horizon_hours (int, default 24,
0 = disabled).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 23:11:50 +00:00
Ubuntu
85d48ce3c9 fix(fire): remove immediate-severity exemption from grouper + cooldown
Fire events carried _severity_override="immediate" which zeroed the
dispatcher cooldown and skipped the Grouper coalescer. This meant fire
had no rate control in normal live operation. Drain-mode pacer handles
reconnect bursts; this change closes the live-operation gap so fire
obeys the toggle cooldown_seconds like every other family.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 23:01:15 +00:00
Ubuntu
de69a9fd57 feat(satpass): consolidate per-observer broadcasts into single regional pass
Satellite passes were broadcasting 3x (once per observer: Filer, Boise,
Idaho Falls). Now accumulates all observers for the same satellite+hour
into satpass_pending, waits 5s for stragglers, then emits one consolidated
broadcast showing entry→exit sweep (e.g. "Filer→Idaho Falls").

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 22:52:25 +00:00
Ubuntu
20cae52408 merge: fire spam drain pacer (de50414) into main
Fixes post-reconnect fire broadcast spam: severity downgrade
(immediate→priority), FIFO output pacer (≤1/min), drain mode
with per-IrwinID decision pass on NATS backlog catch-up.
2026-06-15 21:37:56 +00:00
Ubuntu
de50414471 fix(drain): add missing asyncio import for call_later
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 21:26:32 +00:00
Ubuntu
9ecd8652e1 fix(drain): add 30s timeout for empty-backlog drain exit
If LAST_PER_SUBJECT has no pending messages (container was only down
briefly), no _on_message callback fires and drain mode never exits.
Add a call_later timeout that auto-completes drain after 30s of no
num_pending==0 trigger.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 21:25:09 +00:00
Ubuntu
af4d2a3697 fix(drain): msg.metadata is a property, not a method
nats-py's Msg.metadata is a property returning a Metadata object
directly — not an async callable. Removes the erroneous () call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 21:22:06 +00:00
Ubuntu
2f677e85a1 fix(fire): drain-mode pacer to prevent post-reconnect broadcast spam
After a NATS consumer outage, LAST_PER_SUBJECT delivery floods thousands
of events in seconds. Fire events with _severity_override="immediate"
bypassed the Grouper and zeroed dispatcher cooldowns, causing duplicate
"New" broadcasts for the same fire.

Three-part fix:
- Downgrade fire severity from "immediate" to "priority" so pipeline
  guards (Grouper, cooldown) apply normally
- Add FirePacer (FIFO queue, <=1 fire broadcast/min) for rate-limiting
- Add drain mode to CentralConsumer: suppress bus.emit() during backlog
  catch-up, then run a decision pass per fire IrwinID against final DB
  state (New/Update/Closure/Silence) and route through pacer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-15 21:19:00 +00:00
Claude
1557f3f5b7 merge: satpass compass fallback (cb8a2af) into main 2026-06-13 16:36:30 +00:00
Claude
cb8a2af075 fix(satpass): fall back to raw azimuth degrees for compass directions
satpass_predict envelopes carry only raw azimuth_at_aos/los/peak as
float degrees — they lack the precomputed _compass string fields that
n2yo envelopes provide. The handler read only the _compass fields,
producing empty compass directions ("→") in broadcast wire text.

Apply _azimuth_to_compass() as fallback when _compass strings are
absent, preserving the existing string-field preference for n2yo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-13 15:20:47 +00:00
Claude
e2e065dda0 merge: satpass Tier1+2, fire recency, tombstone path, guard fix into main 2026-06-13 08:00:37 +00:00
Claude
2318f76015 fix(satpass): staleness guard — reject passes whose window already ended
Late-delivered or redelivered events for passes with los_epoch < now
were broadcasting as if upcoming.  Guard added after los_epoch parse,
before dedup/DB work.

Ongoing passes (aos past, los future) still broadcast.  los_epoch=None
falls through unchanged.

Existing tests pinned to fixed now= values to avoid false staleness
rejections on hardcoded envelope timestamps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-13 01:10:58 +00:00
Claude
f94cf20953 fix(satpass): coerce norad_ids to int set at comparison site
GUI saves norad_ids as JSON strings (["25544"]), wire delivers norad_id
as int.  Membership test `25544 in ["25544"]` was False — opt-in list
silently matched nothing.

Build allow_set as {int(x) for x in norad_ids_raw if str(x).isdigit()},
accept both string and int shapes forever.  Garbage entries silently
skipped.  satpass_cmd already coerces via [int(x) for x in cfg_ids].

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 23:36:18 +00:00
Claude
17808c3d65 feat(satpass): GUI safety controls panel — armed-state banner, dry-run toggle, rate cap
Tracking panel gains both new adapter-config keys:
- dry_run toggle (sky-blue accent, visually distinct from enabled)
- max_broadcasts_per_hour number input (1–60 range)

Armed-state banner at top of panel:
- OFF (grey) when disabled
- DRY RUN (sky-blue) when enabled + dry_run
- ⚠ LIVE (amber, pulsing) when enabled + !dry_run

Load/save through adapter-config API per 199929f pattern.
NORAD IDs helper updated to reflect opt-in semantics.

Bundle hash: index-DPN58SF4.js → index-Di1mw816.js
             index-CB06j1ej.css → index-WwNJt5S-.css

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 23:26:08 +00:00
Claude
e903444356 fix(satpass): broadcast safety controls — opt-in filter, rate cap, dry-run, wire format
Incident response for 343 broadcasts in 126s (2026-06-12 22:10 UTC).

Five safety controls:

1. OPT-IN BIRD FILTER: norad_ids=[] now means "broadcast nothing"
   (was "all birds"). Empty list logs once at INFO and suppresses all
   broadcasts. The !satpass DM command remains ungated — it queries
   any bird in the TLE cache using command_norad_ids as bare-command
   default. Two paths, two rules.

2. RATE CAP: new satpass.max_broadcasts_per_hour (int, default 4).
   Excess qualifying passes logged and suppressed. Broadcast path only.

3. DRY-RUN MODE: new satpass.dry_run (bool, default TRUE). Logs exact
   wire text at INFO prefixed "DRY-RUN would air:" without dispatching.
   Go-live: enabled=true + dry_run=true → observe → dry_run=false.

4. ELEVATION DEFAULT: min_elevation REGISTRY default already at 30
   (confirmed, no change needed).

5. BROADCAST WIRE FORMAT: two-line LoRa-tight format with buckets:
     🛰️ {name} {bucket}, {aos_compass}→{los_compass}
     {duration} minute window, {rise}–{set} {AM/PM} MDT
   Buckets: overhead (≥60°), high pass (30-59°), low pass (<30°).
   DM format keeps exact degrees. One format_pass() function with
   broadcast= mode switch — two callers, one function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 23:00:03 +00:00
0cb175d65b fix: correct satpass_handler wire field names + add pass. category prefix
satpass_handler.py: fix 6 field reads to match actual Central NATS
wire names (max_elevation_deg, satellite_name, observer_name/slug,
aos_time, los_time, azimuth_at_peak_compass).

consumer.py: add pass. prefix to _CATEGORY_MAP so
pass.n2yo_visualpasses routes to sat_pass instead of default-deny.
Add sat domain fallback to _SUBJECT_DOMAIN_CATEGORY.

Tests: 9 new tests using verbatim live NOAA-18 envelope from
central.sat.pass.us.id.filer (2026-06-10). Updates existing
test_satpass_handler.py fixtures to match corrected wire names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 20:55:01 +00:00
199929ff1c fix: GUI satpass enabled toggle now persists through adapter-config API
The Tracking panel toggle for satpass.enabled was silently dropped on
save because SatpassConfig lacked the enabled field. The toggle wrote
to a phantom env.satpass.enabled (not in EnvConfig) which the
environmental config endpoint ignored. Handlers read enabled from the
adapter_config SQLite table, which was never updated.

Changes:
- Add enabled:boolean to SatpassConfig interface + initial state
- Convert GET /api/adapter-config/satpass array response to keyed dict
  so load actually reads saved values (fixes pre-existing load bug)
- Wire enabled into the save path via saveAdapterConfig PUT
- Override AdapterPanel enabled/onEnabled for satpass to use
  satpassConfig instead of the phantom env field
- Add test_bool_roundtrip.py: 6 tests proving PUT true/false round-
  trips through DB (value_json) and accessor (Python bool), plus
  second+third adapter boolean round-trips (wfigs, fires)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 20:16:07 +00:00
116e66369e test: satpass event path — wire adapter routing + enabled gate
13 tests covering the full sat event routing chain:

- CENTRAL_ADAPTER_TO_SOURCE maps celestrak_tle, n2yo_visualpasses,
  and satpass_predict to 'satpass' (3 tests)
- No stale adapter names (sat_passes, sat_tles, sat_tle) in map
- TLE handler inserts sat_tles row when enabled (integration)
- TLE handler drops when satpass.enabled=false
- Pass handler accepts n2yo_visualpasses and satpass_predict envelopes
- Pass handler rejects celestrak_tle envelope
- REGISTRY key is min_elevation (not min_elevation_deg)
- Handler source reads min_elevation (not min_elevation_deg)
- Consumer dispatch source has correct wire names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 17:19:52 +00:00
be4443feb9 fix: wire adapter name mismatch in satpass event path
Central publishes sat events with wire adapter names celestrak_tle,
n2yo_visualpasses, and satpass_predict.  Task C's routing matched on
sat_tles/sat_tle/sat_passes — names that exist nowhere on the wire —
so every sat event was silently dropped at three kill points:

1. CENTRAL_ADAPTER_TO_SOURCE map (consumer.py line ~216)
2. _normalize dispatch conditionals (consumer.py line ~549)
3. satpass_handler adapter guard (satpass_handler.py line ~118)

All three now use the correct wire names.  Both handlers also gain an
enabled gate that returns None when satpass.enabled=false, logging at
INFO once per process lifetime via a _disabled_logged function flag.

Existing test fixtures updated for the new adapter names and the
enabled gate (cfg.enabled=True in mock, _disabled_logged cleanup).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 17:19:44 +00:00
4a17c7b702 fix: add satpass to region rewrite table in _subjects_for()
_subjects_for() consults a per-adapter region table when region is set
(e.g. us.id). The table had no satpass entry, so it returned [] and
Central silently subscribed to nothing for sat subjects.

Changes:
- Add satpass entry to the region table: pass alerts use Convention A
  (central.sat.pass.{region}.>), TLEs are global (central.sat.tle.>)
- Update test_central_region_default_propagates_to_consumer_subjects
  to expect satpass subjects (satpass defaults to feed_source=central)
- Add 3 tests in test_satpass_registration.py: _subjects_for with
  region, without region, and empty region fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 15:33:25 +00:00
aa2838fee3 fix: register satpass adapter on EnvironmentalConfig
_subject_owned() iterates _SUBJECTS_BARE keys and calls
getattr(self._env, attr) — but EnvironmentalConfig had no satpass
attribute, so Central never subscribed to central.sat.> subjects.

Changes:
- Add SatpassConfig(_SourcedFeed) with feed_source=central default
- Add satpass field to EnvironmentalConfig
- Add satpass YAML parsing handler in _dict_to_dataclass
- Add (satpass, enabled) to adapter_config REGISTRY
- Fix 3 satpass REGISTRY entries: type list -> json (vocabulary bug)
- Update stale count assertions (SCHEMA 16->17, REGISTRY 84->90,
  ADAPTER_META 21->23)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 07:39:44 +00:00
5418ed1f65 feat(satpass): Tier 2 on-demand satellite pass predictions (!satpass command)
Four-part implementation:
- Part 1: TLE cache handler subscribes central.sat.tle.>, upserts latest-wins
  on epoch, 14-day staleness exclusion at read time (tle_handler.py)
- Part 2: SGP4 pass predictor with 30s step propagation, ECI→topocentric
  look angles, grouped into PassInfo objects (pass_predictor.py)
- Part 3: ZCTA centroid CSV vendored (33,144 ZIPs), lazy-loaded for
  ZIP→lat/lon lookup with no runtime network dependency
- Part 4: !satpass command with three forms: bare (adapter_config defaults),
  name/NORAD ID, ZIP code observer. Location chain: GPS→ZIP→ask.
  Reply formatted as 'ISS 09:36-09:43 MDT max 64deg SW->NE'

Schema v16→v17 (sat_tles table). sgp4>=2.22 added to requirements.
24 new tests covering TLE upsert/staleness, pass prediction reference,
ZCTA lookup, command routing, location chain, and reply format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 06:36:32 +00:00
886bb6510b fix: tombstone broadcast path — immediate severity + commit handles
Tombstone closure broadcasts were silently dropped because the handler
set severity to routine (below the dispatcher floor) and never called
_attach_commit_handles(), leaving event_log.handled=0 permanently.

Changes:
- wfigs_handler.py: tombstone severity routine → immediate
- wfigs_handler.py: attach commit handles with dedup suffix closed
- wfigs_handler.py: use _log_event_returning_id so commit callback
  can flip handled=1 on successful broadcast
- env_reporter.py: build_env_summary fire count excludes tombstoned
  and 100%-contained fires (matches build_fires_detail WHERE)
- tests/test_tombstone_broadcast.py: 11 tests covering severity,
  commit handles, dedup suffix, callback, closure wire, and
  env_summary exclusion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 05:48:25 +00:00
b5a8f5cf97 fix(fire_digest): trim lines to name + location, correct tail count after budget trim
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-12 04:39:57 +00:00