Migrate the three FIRMS fire-tracker BROADCAST paths (wildfire_growth,
wildfire_spotting, wildfire_halted) behind the registry, NO cutover.
Attribution/pass/centroid/perimeter plumbing stays inline; legacy live
path is byte-identical.
- gating/firms.py decide() discriminates on handler-stamped _kind
(firms_growth/firms_spotting/firms_halt)
- wildfire_growth reuses formatters/fire.py (verified byte-identical:
growth SELECT uses current_* columns _render doesn't read, so the wire
is the movement+anchor line with "size/containment unknown" — a latent
legacy quirk, reproduced exactly, NOT fixed)
- formatters/firms.py renders spotting + halt wires
- tier-b (flagged): the eager latch writes (fires.last_spotting_broadcast_at,
fires.halt_broadcast_at) move into deferred commit closures — a dropped
broadcast no longer burns the latch. Validated by gate-sequence, not
golden bytes. Not-cutover live path keeps eager latches verbatim.
- FIRMS broadcasts never touch event_log (eager handled=1 at pixel
storage) → no event_log flip to wrap
- _maybe_emit_cluster stays dead (unconditional return None) + test
Deferred follow-ups (unchanged): env/firms.py native hotspot broadcast
neutralization; native canonical emission.
27 new tests; fire-tracker + firms handler suites preserved; full suite
at 34-failure baseline (1597 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move wfigs wire rendering into notifications/formatters/fire.py and the
full fire state machine into notifications/gating/fire.py, behind the
registry, NO cutover. handle_wfigs builds a canonical dict, calls
decide(), keeps the inline fires INSERT/UPDATE of current_* and the
tombstoned_at stamp unconditional, then branches on is_cutover(...) —
legacy _attach_commit_handles/all-clear path stays byte-identical while
the new path bakes in shadow.
Reproduces every legacy stamp through GateResult.data_patch:
- forward-only acres/containment growth + 8h cooldown gating
- tombstone wildfire_closed all-clear (row exists AND last_broadcast_at
IS NOT NULL) with _severity_override="priority", _dedup_suffix="closed"
- growth _dedup_suffix=f"{acres}|{contained_pct}", _cooldown_suffix=irwin_id
- idempotent commit UPSERT of fires(last_broadcast_*) + event_log flip
- full _location_anchor fallback chain (geocoder_city -> resolve_anchor
-> landclass -> county -> state) preserved in the formatter
Registered under the three explicit categories (wildfire_declared,
wildfire_incident, wildfire_closed) rather than the `fire` toggle, so the
family-fallback does NOT capture the still-deferred FIRMS categories
(wildfire_hotspot/new_ignition/wildfire_growth); a registration test
asserts those resolve elsewhere.
Native env/fires.py deferred (missing IRWIN/cause/landclass, no tombstone
concept); non-cutover so store._emit_event won't run it.
tier-a: 19 new golden+gate-sequence tests; wfigs handler 23/23 preserved;
suite at 34-failure baseline (1571 passed).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move nwis wire rendering into notifications/formatters/hydro.py and the
threshold-crossing decision into notifications/gating/hydro.py, behind
the registry under the real category `stream_flow`. handle_nwis now
builds a canonical dict, calls decide(), keeps the gauge_readings INSERT
inline (append-only), and branches on is_cutover("stream_flow") exactly
like quake_handler — legacy _attach_commit path stays byte-identical
while the new path bakes in shadow. No cutover.
- gauge_readings INSERT stays inline; decider only reads prior state
- hydro has no per-event broadcast-state table → GateResult.commit=None;
event_log.handled flip stays handler-owned in the cutover wrapper
- tier-a: golden byte-identical wire + gate-sequence parity (12 tests)
- native env/usgs.py deferred (different category vocab, no
threshold_state); non-cutover so store._emit_event won't run it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
shadow_gate called decider(data, now=now), omitting the required
keyword-only `source` arg from the decide(data, *, source, now)
contract. Every decider raised TypeError (swallowed at DEBUG), so the
Phase-1/2 gate-shadow was a silent no-op the entire bake and wrote zero
mismatch records.
- shadow.py:139 -> decider(data, source=source, now=now)
- add TestShadowGateForwardsSource regression guard (fake decider that
asserts source is forwarded; fails pre-fix, passes post-fix)
- retarget TestShadowInertWhenNoDecider at a genuinely-unregistered
category (__no_such_category__); it was using earthquake_event, whose
decider only "passed" before because the signature bug crashed it
Suite at 34-failure baseline.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(fixtures): rewrite capture_fixtures.py to use ephemeral push subscribe
Previous script hardcoded stream="CENTRAL" which does not exist — Central
partitions into domain streams (CENTRAL_QUAKE, CENTRAL_SPACE, etc.). It
also called pull_subscribe_bind() without await, making the fetch a no-op.
Fix: mirror the proven CentralConsumer.start() pattern — use
js.subscribe(subject, cb=..., AckPolicy.NONE, no durable) which auto-
discovers the correct stream via the subject filter, identical to how the
live consumer binds. Messages are funnelled through asyncio.Queue with
an idle-timeout to detect drain completion.
Adds live captured fixtures:
- tests/fixtures/quake/ — 3 envelopes (CENTRAL_QUAKE stream, mode=all)
- tests/fixtures/swpc/ — 40 envelopes (CENTRAL_SPACE, mode=all, proton_flux history)
- tests/fixtures/swpc_last/ — 23 envelopes (mode=last: 21 alert variants + kindex + proton_flux)
Avalanche: confirmed empty off-season (CENTRAL_AVY stream, 0 messages).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(phase1): migrate quake, swpc(Kp+flare), avalanche + staged-cutover gate
First hazards on the source-agnostic formatter+decider path, behind a
staged-cutover gate so deploy = shadow-only (old path still broadcasts; new
path dry-run-diffed) until MESHAI_CUTOVER_CATEGORIES flips a category live.
- formatters/{quake,swpc,avalanche}.py + gating/{quake,swpc,avalanche}.py:
source-agnostic format(event,*,now,budget) + decide(data,*,source,now)->GateResult.
quake (earthquake_event, tier-b: render PAGER + live update-prefix), swpc
(geomagnetic_storm + rf_propagation_alert, Kp+flare only; proton/solar_radiation
_storm stays legacy; geomag 600s window re-homed off the module-global into
gating/swpc with a deferred stamp; tier-b scale-based severity), avalanche
(avalanche_warning/watch; centralseverity->NAADS 1-5 remap; synthetic fixtures
off-season).
- central/{quake,swpc,avy}_handler.py bridges: cutover -> new decide()+canonical
data; not-cutover -> exact legacy behavior. env/{usgs_quake,swpc,avalanche}.py
emit canonical Event.data (avalanche stops precomposing). env/store.py generic
native decider hook (cutover-gated).
- notifications/cutover.py (is_cutover via MESHAI_CUTOVER_CATEGORIES); composer
dispatch + shadow hooks are cutover-aware (shadow no-ops once a category is live).
- scripts/capture_fixtures.py fixed (per-domain streams e.g. CENTRAL_QUAKE, await
bind); real quake/swpc fixtures captured; avalanche synthetic.
Tests: +~150 (quake/swpc/avalanche parity+cross-source+gate-sequence+cutover);
0 new failures (34 baseline, 1426 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Foundation for making all hazard formatting+gating source-agnostic. ZERO
behavior change — the formatter/decider registries are empty (get_formatter/
get_decider return None → existing precomposed/Mode-B path preserved), and the
shadow comparator is off unless MESHAI_SHADOW_CATEGORIES is set.
- notifications/formatters/ (registry+dispatch with family fallback), gating/
(GateResult + deferred-commit contract), both empty registries.
- notifications/clock.py determinism seam; route wfigs/quake/nws gating time
reads through it (identical values) so goldens can freeze time.
- formatters/_budget.py = copy of central/budget.py; central/budget.py is now a
re-export shim (import-smoke test guards it).
- compose_mesh_message consults the registry first (verbatim, no Mode-B re-cap),
falls back to legacy; _resolve_budget injects per-category budget.
- notifications/shadow.py + two DRY-RUN hooks (consumer._normalize, dispatcher
render): compute the new result and diff-log SHADOW_MISMATCH JSONL, but NEVER
commit/emit/write tables and always broadcast the OLD result. Inert by default.
- tests/harness (pinned_time/pinned_tz, byte-golden, gate-sequence) +
scripts/capture_fixtures.py (ephemeral read-only NATS capture); tzdata pinned.
Tests: +60 (18 scaffold + 42 harness/shadow); 0 new failures (34 baseline).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Central NATS consumer's start() was called unguarded during boot, so if
Central was enabled but unreachable at startup, nats.connect() raised
NoServersError, propagated through bot.start(), and crashed the process —
crash-looping under Docker restart:unless-stopped.
Now _start_central_consumer_guarded() wraps start() in try/except: on failure
it logs a warning and continues booting (LLM bot, Meshtastic/MeshCore,
mesh-health, and native feeds all start), then a background retry loop
(30s->300s backoff) re-attempts the initial connect until it succeeds. Once
connected, NATS's own allow_reconnect handles runtime drops. The retry task is
cancelled cleanly on stop(). No retry is scheduled when nothing is
central-sourced.
Tests: +tests/test_central_boot_guard.py (11); 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Native adapters vs. Central" subsection: each hazard feed chooses its
data path with feed_source (native = direct public-API fetch, the default and
original path; central = subscribe to Central's pre-aggregated NATS JetStream
firehose). Documents the environmental.central config, mutual exclusivity,
the satpass (central-only) / ducting (native-only) exceptions, runtime
auto-reconnect behavior, and the startup-needs-Central caveat.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite the README to reflect the current project: dual-transport
(Meshtastic base + MeshCore auto-on via meshcore_host), the web dashboard
(with live screenshots), per-mesh routing, the conversational bot with
per-mesh scoped context + three privacy lanes, environmental/hazard
broadcasts, mesh-health scoring, and the RAG knowledge base. Removes the
retired subscription backend/commands, updates the LLM model + architecture,
and adds live dashboard screenshots under docs/images/.
For transparency, documents that the project was vibecoded (built with LLM
coding assistants).
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pyMC-companion FLOOD direct-messages are silently rejected by recipient
MeshCore nodes (43 sent / ~1 ack in 24h); DIRECT-routed packets deliver.
A bare inbound DM does NOT populate the contact's out_path on pyMC, so the
contact stays out_path_len=-1 (flood), and send_msg_with_retry actively
reset_path→flood, guaranteeing the broken route.
New behavior on a DM reply:
- _establish_direct_path(): path discovery (CMD 52 send_path_discovery_sync)
so the recipient returns a PATH packet → pyMC writes a real out_path →
works at ANY hop count. Fallback: seed from the sender's cached advert
(get_advert_path CMD 42 → update_contact CMD 9) if discovery is empty.
- send via plain send_msg (CMD 2) — uses the learned path → DIRECT. Drops
send_msg_with_retry (which forced flood). Logs the RESP_CODE_SENT route
(direct/flood) so we can confirm.
Tests reworked for the discover-then-direct-send path; 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MeshCore DM replies were passed a bare 6-byte pubkey prefix to
send_msg_with_retry. Per every working meshcore project (meshcore_py
examples, meshcore-cli, meshcore-bot, meshcore-ha), the destination must be
resolved to the FULL contact object (dict w/ 64-hex public_key) after
ensure_contacts — otherwise the lib can't upgrade the prefix to the full key,
skips reset_path, blind-floods, gets no ACK, and the DM silently never
delivers (matches our live symptom: inbound + channel send work, DM reply dies).
- Add _resolve_contact(dest): ensure_contacts() then get_contact_by_key_prefix().
- DM branch now passes the resolved contact object to send_msg_with_retry;
if the contact can't be resolved, log + return False (no blind-flood).
- Subscribe to EventType.ACK + log received ACKs (instrumentation to confirm
whether ACKs reach the dispatcher at all).
Tests updated (+2); 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four fixes surfaced by live testing (a MeshCore DM got no reply):
- DM REPLY DELIVERY (root cause): reply used the meshcore lib's fire-and-forget
send_msg (MSG_SENT != delivered, no flood, no ACK) so replies to nodes without
an established direct path silently vanished. Switch to send_msg_with_retry
(contact resolve + flood fallback + ACK wait); a None return (no ACK) is now a
real failure, not silent success. _run_coro timeout raised to 40s for the ACK cycle.
- 422 on save: register meshcore_context in config_loader SECTION_TO_FILE
(config.yaml) — it was in VALID_SECTIONS but not the save-routing table.
- test-llm endpoint: called backend.generate() with (str, []) instead of
(messages:list, system_prompt:str) → "string indices" error; fixed the call.
- Inbound observability + robustness: subscribe to CONTACT_MSG_RECV BEFORE
start_auto_message_fetching (+ ensure_contacts) so a DM queued at connect isn't
drained before the handler registers; add INFO/DEBUG logging across the inbound
DM + dispatch + send path (was entirely unlogged).
Tests: +test_meshcore_dm_delivery, +test_fix_meshcore_save_and_llm_test; 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Config → Settings → Context: relabel the raw "Max Age (sec)" field to
"Chat context retention (days)" (days<->seconds conversion, min 1,
default 14). Governs the shared per-mesh chat memory window.
- Make PUT /api/config/context apply LIVE: MeshContext.update_settings()
updates max_age/observe_channels/ignore_nodes in place; config_routes
refreshes the running MeshContext via app.state.mesh_context (mirrors
the existing _refresh_toggle_filter pattern) so retention changes take
effect without a restart.
Tests: +tests/test_context_hot_reload.py (10); 0 new failures.
Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* 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>
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>
* 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>
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>
* 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>
* 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>
* 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>
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>
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>
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>
* 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>
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>
* 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>
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>
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>
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>
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>
- 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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>