upd({ meshcore_auto_add_contacts: v })}
helper="Enables firmware CMD 58 (set_autoadd_config) at connect so AIDA automatically adds every node it hears an advert from as a contact, enabling DM send/decrypt without manual contact exchange"
/>
@@ -418,13 +466,13 @@ export default function MeshCoreConnection() {
upd({ meshcore_auto_reconnect: v })}
helper="Automatically reconnect to the MeshCore companion if the link drops"
/>
upd({ meshcore_max_reconnect_attempts: v })}
min={0}
helper="Maximum reconnect attempts before giving up (0 = unlimited)"
diff --git a/work/docker-compose.yml b/work/docker-compose.yml
index e2b1e3a..a30a665 100644
--- a/work/docker-compose.yml
+++ b/work/docker-compose.yml
@@ -75,11 +75,11 @@ services:
memory: 64M
healthcheck:
- test: ["CMD-SHELL", "test -f /tmp/meshai.pid && kill -0 $(cat /tmp/meshai.pid) 2>/dev/null && [ \"$(cat /tmp/meshai.link 2>/dev/null)\" = up ] || exit 1"]
+ test: ["CMD-SHELL", "curl -f -s -o /dev/null http://localhost:8080/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
- start_period: 15s
+ start_period: 240s
logging:
driver: "json-file"
diff --git a/work/meshai/env/store.py b/work/meshai/env/store.py
index 06109a1..15f295b 100644
--- a/work/meshai/env/store.py
+++ b/work/meshai/env/store.py
@@ -1,5 +1,6 @@
"""Environmental data store with tick-based adapter polling."""
+import asyncio
import hashlib
import json
import logging
@@ -438,20 +439,38 @@ class EnvironmentalStore:
from meshai import coverage as _cov
return _cov.resolve_adapter_coverage(adapter, self._coverage_bbox, "native")
- def refresh(self) -> bool:
+ async def refresh(self) -> bool:
"""Called every second from main loop. Ticks each adapter.
+ Adapter tick() calls (blocking network I/O) run concurrently in
+ worker threads and are AWAITED to completion before ingest, so the
+ event loop stays responsive while fetches are in flight. Ingest
+ (DB/EventBus work) then runs on the loop thread once all ticks are
+ done, exactly as before, so no thread ever overlaps ingest.
+
Returns:
True if any data changed
"""
changed = False
- for name, adapter in self._adapters.items():
- try:
- if adapter.tick():
- changed = True
+ adapters = list(self._adapters.items())
+ if not adapters:
+ self._purge_expired()
+ return changed
+
+ results = await asyncio.gather(
+ *(asyncio.to_thread(adapter.tick) for _, adapter in adapters),
+ return_exceptions=True,
+ )
+ for (name, adapter), result in zip(adapters, results):
+ if isinstance(result, Exception):
+ logger.warning("Env adapter %s error: %s", name, result)
+ continue
+ if result:
+ changed = True
+ try:
self._ingest(name, adapter)
- except Exception as e:
- logger.warning("Env adapter %s error: %s", name, e)
+ except Exception as e:
+ logger.warning("Env adapter %s error: %s", name, e)
self._purge_expired()
return changed
diff --git a/work/meshai/main.py b/work/meshai/main.py
index 33866bf..cffc049 100644
--- a/work/meshai/main.py
+++ b/work/meshai/main.py
@@ -2,6 +2,7 @@
import argparse
import asyncio
+import concurrent.futures
import logging
import os
import signal
@@ -146,13 +147,46 @@ class MeshAI:
while self._running:
await asyncio.sleep(1)
- # Periodic MeshMonitor refresh
- if self.meshmonitor_sync:
- self.meshmonitor_sync.maybe_refresh()
-
- # Periodic data store refresh and health computation
+ # Run the mesh/env/meshmonitor pollers concurrently so blocking
+ # network I/O (tick() fetches) never starves this loop — and
+ # therefore never starves the dashboard, which shares this same
+ # asyncio loop. Each refresh() internally awaits its own due
+ # ticks in worker threads; meshmonitor_sync.maybe_refresh is
+ # synchronous, so it is offloaded to a thread here directly.
+ # We await the WHOLE cycle before the next iteration, so no
+ # tick() thread ever overlaps the next cycle's bookkeeping.
+ _refresh_tasks = {}
+ if self.data_store:
+ _refresh_tasks['data'] = self.data_store.refresh()
+ if self.env_store:
+ _refresh_tasks['env'] = self.env_store.refresh()
+ if self.meshmonitor_sync:
+ _refresh_tasks['mm'] = asyncio.to_thread(self.meshmonitor_sync.maybe_refresh)
+
+ if _refresh_tasks:
+ _refresh_results = dict(zip(
+ _refresh_tasks.keys(),
+ await asyncio.gather(*_refresh_tasks.values(), return_exceptions=True),
+ ))
+ else:
+ _refresh_results = {}
+
+ refreshed = _refresh_results.get('data')
+ if isinstance(refreshed, Exception):
+ logger.warning("Data store refresh error: %s", refreshed)
+ refreshed = False
+
+ env_changed = _refresh_results.get('env')
+ if isinstance(env_changed, Exception):
+ logger.debug("Env refresh error: %s", env_changed)
+ env_changed = False
+
+ _mm_result = _refresh_results.get('mm')
+ if isinstance(_mm_result, Exception):
+ logger.warning("MeshMonitor sync refresh error: %s", _mm_result)
+
+ # Periodic data store health computation
if self.data_store:
- refreshed = self.data_store.refresh()
# Recompute health after refresh
if refreshed and self.health_engine:
self.health_engine.compute(self.data_store)
@@ -202,10 +236,9 @@ class MeshAI:
except Exception:
pass
- # Environmental feed refresh
+ # Environmental feed alerting/broadcast (refresh already ran above)
if self.env_store:
try:
- env_changed = self.env_store.refresh()
if env_changed and self.alert_engine:
env_alerts = self.alert_engine.check_environmental(self.env_store)
if env_alerts:
@@ -930,6 +963,14 @@ def main() -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
+ # Size the default executor generously: mesh sources + env adapters
+ # (~7 sources, ~15 env adapters) now fetch concurrently via
+ # asyncio.to_thread() every tick, so they need thread headroom to avoid
+ # queuing behind each other on the default executor's small pool.
+ loop.set_default_executor(
+ concurrent.futures.ThreadPoolExecutor(max_workers=24, thread_name_prefix="meshai-io")
+ )
+
def signal_handler(sig, frame):
logger.info(f"Received signal {sig}")
loop.create_task(bot.stop())
diff --git a/work/meshai/mesh_data_store.py b/work/meshai/mesh_data_store.py
index 44047db..97c1592 100644
--- a/work/meshai/mesh_data_store.py
+++ b/work/meshai/mesh_data_store.py
@@ -6,6 +6,7 @@ This module replaces mesh_sources.py with a clean three-layer architecture:
- Layer 3: Consumers read unified model (no field guessing)
"""
+import asyncio
import json
import logging
import sqlite3
@@ -441,12 +442,16 @@ class MeshDataStore:
if stale_nums:
logger.info(f"Purged {len(stale_nums)} stale nodes (not heard in {STALE_NODE_THRESHOLD_DAYS} days)")
- def refresh(self) -> bool:
+ async def refresh(self) -> bool:
"""Tick-based refresh. Called every second from the main loop.
- Delegates to source tick() for sources that support it.
- Only does a full rebuild when nodes/edges/topology change.
- Only does a lightweight update when only packets change.
+ Delegates to source tick() for sources that support it. Due sources'
+ tick() calls (blocking network I/O) run concurrently in worker
+ threads and are AWAITED to completion before any bookkeeping, so the
+ event loop (and therefore the dashboard) stays responsive while
+ fetches are in flight. Only does a full rebuild when nodes/edges/
+ topology change. Only does a lightweight update when only packets
+ change.
Returns:
True if any data changed
@@ -456,26 +461,38 @@ class MeshDataStore:
needs_rebuild = False
needs_packet_update = False
+ due: list[tuple[str, object]] = []
for name, source in self._sources.items():
# Check if this source supports tick-based polling
if hasattr(source, 'tick') and hasattr(source, '_tick_interval'):
if now - source._last_tick >= source._tick_interval:
- endpoint = source.tick()
- if endpoint:
- any_changed = True
- # Major changes require full rebuild
- if endpoint in ("nodes", "edges", "traceroutes", "topology", "telemetry"):
- needs_rebuild = True
- # Packet-only changes are lightweight
- elif endpoint in ("packets",):
- needs_packet_update = True
- # stats, counts, channels, solar, network just update cached data
+ due.append((name, source))
else:
# Legacy fallback for sources without tick support
if source.maybe_refresh():
any_changed = True
needs_rebuild = True
+ if due:
+ results = await asyncio.gather(
+ *(asyncio.to_thread(source.tick) for _, source in due),
+ return_exceptions=True,
+ )
+ for (name, source), result in zip(due, results):
+ if isinstance(result, Exception):
+ logger.warning(f"Source {name} tick failed: {result}")
+ continue
+ endpoint = result
+ if endpoint:
+ any_changed = True
+ # Major changes require full rebuild
+ if endpoint in ("nodes", "edges", "traceroutes", "topology", "telemetry"):
+ needs_rebuild = True
+ # Packet-only changes are lightweight
+ elif endpoint in ("packets",):
+ needs_packet_update = True
+ # stats, counts, channels, solar, network just update cached data
+
if needs_rebuild:
self._rebuild()
self._purge_stale_nodes()
diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py
index 9be229d..6d15bba 100644
--- a/work/meshai/transport/meshcore_transport.py
+++ b/work/meshai/transport/meshcore_transport.py
@@ -36,6 +36,13 @@ _TELEMETRY_MIN_INTERVAL_SECONDS = 300
# dropped from the auto-poll rotation (a manual "Poll now" un-sticks it).
_TELEMETRY_MAX_FAILURES = 3
+# --- Companion-link keepalive tuning --------------------------------------
+# MeshMonitor's MeshCore vnode (the shared companion-link server meshai
+# attaches to) reaps any client idle >5 min, where "idle" means no bytes seen
+# FROM the client — a periodic LOCAL query resets that clock. 120s is well
+# inside the 300s reaper window with margin to spare.
+_KEEPALIVE_INTERVAL_SECONDS = 120
+
# Numeric Cayenne-LPP type id → decoded field name. Ids not in this map are
# passed through as ``lpp_`` so nothing is silently dropped.
_LPP_ID_TO_FIELD = {
@@ -132,6 +139,8 @@ class MeshCoreTransport(MeshTransport):
self._advert_task = None
# asyncio.Task handle for the telemetry auto-poll loop; None when inactive.
self._telemetry_task = None
+ # asyncio.Task handle for the companion-link keepalive loop; None when inactive.
+ self._keepalive_task = None
# Telemetry availability/bookkeeping (shared by poller + on-demand):
# _telemetry_cache: contact-id -> {contact, data, polled_at, available}
# _telemetry_failures: contact-id -> consecutive-timeout count
@@ -577,15 +586,37 @@ class MeshCoreTransport(MeshTransport):
)
return acked or (not result.is_error())
+ def _resolve_mc_channel_idx(self, meshcore_channel: str) -> Optional[int]:
+ """Resolve a config channel name to the companion's channel slot.
+
+ Tries an exact match first (fast path, preserves existing behavior
+ for e.g. ``#bot``). Falls back to a match that ignores a single
+ leading ``#`` and case, since after the radio moved to MeshMonitor's
+ vnode the companion enumerates region channels WITHOUT the leading
+ ``#`` that meshai's region_routes config still carries (e.g. config
+ ``#sc-id-aida`` vs. companion ``sc-id-aida``) — same channel/key,
+ just a display-name difference upstream.
+ """
+ idx = self._chan_name_to_idx.get(meshcore_channel)
+ if idx is not None:
+ return idx
+ canon = meshcore_channel[1:] if meshcore_channel.startswith("#") else meshcore_channel
+ canon = canon.casefold()
+ for name, slot in self._chan_name_to_idx.items():
+ name_canon = name[1:] if name.startswith("#") else name
+ if name_canon.casefold() == canon:
+ return slot
+ return None
+
async def _do_mc_broadcast_async(self, text: str, meshcore_channel: str) -> bool:
"""Channel broadcast on the MC loop (replaces send_message() broadcast branch)."""
if self._mc is None:
return False
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
# Lazy async re-enumeration (no _run_coro deadlock risk).
await self._enumerate_channels_async()
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
logger.warning("MC channel '%s' not on companion; skipping", meshcore_channel)
return False
@@ -1776,6 +1807,66 @@ class MeshCoreTransport(MeshTransport):
if task is not None and self._loop is not None and self._loop.is_running():
self._loop.call_soon_threadsafe(task.cancel)
+ # ------------------------------------------------------------------
+ # Companion-link keepalive (Task on the dedicated loop)
+ # ------------------------------------------------------------------
+
+ async def _keepalive_loop(self) -> None:
+ """Quiet LOCAL companion-link keepalive (Task on the dedicated loop).
+
+ MeshMonitor's MeshCore vnode disconnects any client idle >5 min,
+ where "idle" means no bytes seen FROM the client — its
+ ``lastActivity`` only updates on data we send it, never on data it
+ sends us. The self-advert (every 24h by default) and telemetry poll
+ (30 min default, and only when contacts are configured) are both far
+ too infrequent to keep that clock fresh, so the link was silently
+ reaped and never recovered (``meshcore_auto_reconnect`` is the
+ recovery safety net; this loop is the prevention).
+
+ Every ``_KEEPALIVE_INTERVAL_SECONDS`` (while connected), issues
+ ``commands.get_time()`` — a single-byte companion opcode (CMD 0x05)
+ that reads the node's own onboard clock and returns CURRENT_TIME.
+ It carries no destination/contact and has no mesh-routing semantics
+ (unlike send_advert/send_msg/send_chan_msg), so the firmware answers
+ it purely locally over the companion link — it does not key the
+ radio or emit an RF packet. Runs directly on the MC loop (NOT
+ through the send queue/pacing — it is a device-info query, not a
+ mesh send, so it should never wait behind or delay a real send).
+
+ Stops on CancelledError (disconnect). A transient query failure is
+ logged and ignored — the loop keeps ticking every interval either
+ way, since the point is resetting the vnode's clock on our next
+ successful frame, not the query result itself.
+ """
+ try:
+ while True:
+ await asyncio.sleep(_KEEPALIVE_INTERVAL_SECONDS)
+ if not self._connected or self._mc is None:
+ return
+ try:
+ await self._mc.commands.get_time()
+ logger.debug("MC: companion-link keepalive query sent")
+ except Exception as exc:
+ logger.debug("MC: keepalive get_time failed (non-fatal): %s", exc)
+ except asyncio.CancelledError:
+ logger.debug("MC: keepalive task cancelled")
+ raise
+
+ def _schedule_keepalive(self) -> None:
+ """Create the keepalive asyncio.Task on the dedicated loop (thread-safe)."""
+ def _arm() -> None:
+ self._keepalive_task = asyncio.get_event_loop().create_task(
+ self._keepalive_loop()
+ )
+ self._loop.call_soon_threadsafe(_arm)
+
+ def _cancel_keepalive(self) -> None:
+ """Cancel the keepalive task (thread-safe). Called at disconnect."""
+ task = self._keepalive_task
+ self._keepalive_task = None
+ if task is not None and self._loop is not None and self._loop.is_running():
+ self._loop.call_soon_threadsafe(task.cancel)
+
# ------------------------------------------------------------------
# Internal coroutines (run on the dedicated loop)
# ------------------------------------------------------------------
@@ -1963,6 +2054,12 @@ class MeshCoreTransport(MeshTransport):
if telem_interval > 0:
self._schedule_telemetry_poll()
+ # Arm the quiet local companion-link keepalive — unconditional (not
+ # a mesh operation, no config gate): protects against the
+ # MeshMonitor vnode's 5-min idle reaper regardless of advert/
+ # telemetry cadence.
+ self._schedule_keepalive()
+
logger.info(
"MeshCoreTransport: connected as %s (pubkey %s)",
self._self_info.get("name", "unknown"),
@@ -1971,9 +2068,10 @@ class MeshCoreTransport(MeshTransport):
def disconnect(self) -> None:
"""Disconnect and stop the event loop thread."""
- # Cancel periodic advert + telemetry poll before tearing down the loop.
+ # Cancel periodic advert + telemetry poll + keepalive before tearing down the loop.
self._cancel_periodic_advert()
self._cancel_telemetry_poll()
+ self._cancel_keepalive()
if self._mc is not None:
try:
self._run_coro(self._do_disconnect(), timeout=10.0)
@@ -2078,12 +2176,12 @@ class MeshCoreTransport(MeshTransport):
)
return True
# Resolve NAME → slot against the live companion channel table.
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
# One lazy re-enumeration in case the table changed since
# connect (e.g. a channel was provisioned after startup).
self._enumerate_channels()
- idx = self._chan_name_to_idx.get(meshcore_channel)
+ idx = self._resolve_mc_channel_idx(meshcore_channel)
if idx is None:
# Never blind-send to a guessed slot.
logger.warning(
@@ -2263,10 +2361,65 @@ class MeshCoreTransport(MeshTransport):
self._connected = False
logger.warning("MeshCoreTransport: DISCONNECTED event received")
+ async def _post_reconnect_setup_async(self) -> None:
+ """Redo connect()'s LOCAL post-connect setup after an auto-reconnect.
+
+ connect() does this setup once, on the initial connect: rebuild
+ ``_chan_name_to_idx`` (so channel-name broadcasts can resolve a
+ slot) and arm the companion-link keepalive (so MeshMonitor's vnode
+ doesn't reap the link again at 5 min idle). The meshcore lib's
+ auto-reconnect only re-establishes the socket and fires CONNECTED
+ (-> ``_on_connect_event``) — it does not repeat that setup, so a
+ reconnected link was left with an empty channel table and no
+ keepalive until the reaper cut it again.
+
+ Both steps here are local companion queries/timers only —
+ ``_enumerate_channels_async`` calls ``get_channel()`` and the
+ keepalive calls ``get_time()`` (see their docstrings); neither
+ keys the radio or emits an RF packet. This deliberately excludes
+ connect()'s ``send_advert()`` — that IS a transmission, and must
+ stay confined to the initial connect() path, never replayed on
+ reconnect.
+
+ Keepalive re-arm is cancel-then-schedule (idempotent) so it never
+ double-schedules the task.
+
+ Runs as a fire-and-forget task on the dedicated MC loop (see
+ ``_on_connect_event``) rather than being awaited inline via
+ ``_run_coro``: the meshcore lib invokes ``_on_connect_event`` from
+ within that same loop (like ``_on_new_contact``), so a blocking
+ ``_run_coro().result()`` call here would deadlock it.
+ """
+ try:
+ await self._enumerate_channels_async()
+ except Exception:
+ logger.warning(
+ "MeshCore: post-reconnect channel re-enumeration failed", exc_info=True
+ )
+ try:
+ self._cancel_keepalive()
+ self._schedule_keepalive()
+ except Exception:
+ logger.warning(
+ "MeshCore: post-reconnect keepalive re-arm failed", exc_info=True
+ )
+
def _on_connect_event(self, event=None) -> None:
- """Track link state: CONNECTED (auto-reconnect succeeded)."""
+ """Track link state: CONNECTED (auto-reconnect succeeded).
+
+ Schedules ``_post_reconnect_setup_async`` fire-and-forget on the
+ dedicated MC loop — see that method's docstring for why this must
+ not block (``_run_coro`` would deadlock from inside this callback,
+ exactly as noted in ``_on_new_contact``).
+ """
self._connected = True
logger.info("MeshCoreTransport: CONNECTED event received")
+ try:
+ loop = getattr(self, "_loop", None)
+ if loop is not None and loop.is_running():
+ asyncio.run_coroutine_threadsafe(self._post_reconnect_setup_async(), loop)
+ except Exception:
+ logger.debug("MeshCore: scheduling post-reconnect setup failed", exc_info=True)
# ------------------------------------------------------------------
# Node identity / topology (MeshTransport abstract methods)
diff --git a/work/tests/test_generic_http.py b/work/tests/test_generic_http.py
index 2b40fab..854aa76 100644
--- a/work/tests/test_generic_http.py
+++ b/work/tests/test_generic_http.py
@@ -8,6 +8,7 @@ Ported-behavior coverage:
* geometry-path Point -> centroid extraction
"""
from __future__ import annotations
+import asyncio
import json
@@ -203,7 +204,7 @@ def test_cold_start_silent_first_poll_seeds_persists_no_emit():
# Stub the network fetch with one active outage.
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
- store.refresh() # poll 1 == pre-existing backlog
+ asyncio.run(store.refresh()) # poll 1 == pre-existing backlog
# Nothing broadcast on the cold-start poll...
assert captured == [], "first poll must broadcast NOTHING (cold-start seed)"
@@ -220,14 +221,14 @@ def test_cold_start_silent_first_poll_seeds_persists_no_emit():
def test_later_poll_broadcasts_newly_received_item():
store, adapter, captured = _make_store_with_generic()
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
- store.refresh() # poll 1 — seed silently
+ asyncio.run(store.refresh()) # poll 1 — seed silently
assert captured == []
# A genuinely NEW outage appears on a later poll -> it must broadcast.
new_item = dict(IDAHO_POWER_ITEM, omsOutageId="456", omsCustomerCount=99)
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM, new_item])
adapter._last_poll.clear() # force cadence to elapse
- store.refresh() # poll 2
+ asyncio.run(store.refresh()) # poll 2
assert len(captured) == 1, "only the newly-received outage broadcasts"
assert captured[0].category == "power_outage"
@@ -367,7 +368,7 @@ def test_build_generic_detail_reader():
from meshai.notifications.env_reporter import EnvReporter
store, adapter, captured = _make_store_with_generic()
adapter._fetch = lambda url, extra_headers=None: _payload([IDAHO_POWER_ITEM])
- store.refresh()
+ asyncio.run(store.refresh())
text = EnvReporter().build_generic_detail()
assert "idaho_power" in text
diff --git a/work/tests/test_store_received_delta.py b/work/tests/test_store_received_delta.py
index 77555fc..1b253ef 100644
--- a/work/tests/test_store_received_delta.py
+++ b/work/tests/test_store_received_delta.py
@@ -14,6 +14,7 @@ These tests drive the real EnvironmentalStore + EventBus with a fake adapter
whose per-poll batch we control, and assert exactly which events reach the bus.
"""
from __future__ import annotations
+import asyncio
from meshai.env.store import EnvironmentalStore, _key_ext
from meshai.config import EnvironmentalConfig
@@ -79,7 +80,7 @@ def test_first_poll_seeds_and_broadcasts_nothing():
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # poll 1 — the backlog
+ asyncio.run(store.refresh()) # poll 1 — the backlog
assert captured == [], "first poll must broadcast NOTHING (backlog seed)"
@@ -88,11 +89,11 @@ def test_second_poll_emits_only_newly_received():
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # poll 1: seed
+ asyncio.run(store.refresh()) # poll 1: seed
assert _emitted_ids(captured) == []
adapter.set_batch(["A", "B", "C", "D"])
- store.refresh() # poll 2: only D is new
+ asyncio.run(store.refresh()) # poll 2: only D is new
assert _emitted_ids(captured) == ["D"]
@@ -100,11 +101,11 @@ def test_unchanged_poll_emits_nothing():
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # poll 1: seed
+ asyncio.run(store.refresh()) # poll 1: seed
adapter.set_batch(["A", "B", "C", "D"])
- store.refresh() # poll 2: D
+ asyncio.run(store.refresh()) # poll 2: D
adapter.set_batch(["A", "B", "C", "D"])
- store.refresh() # poll 3: nothing new
+ asyncio.run(store.refresh()) # poll 3: nothing new
assert _emitted_ids(captured) == ["D"], "poll 3 has no new items"
@@ -113,21 +114,21 @@ def test_restart_reseeds_and_never_rebroadcasts_backlog():
# Process 1 sees A,B,C,D and broadcasts D.
store1, adapter1, cap1 = _make_store()
adapter1.set_batch(["A", "B", "C"])
- store1.refresh()
+ asyncio.run(store1.refresh())
adapter1.set_batch(["A", "B", "C", "D"])
- store1.refresh()
+ asyncio.run(store1.refresh())
assert _emitted_ids(cap1) == ["D"]
# RESTART: a fresh store has an empty seen-set. The SAME backlog [A,B,C,D]
# arriving on its first poll must be re-seeded silently, not re-broadcast.
store2, adapter2, cap2 = _make_store()
adapter2.set_batch(["A", "B", "C", "D"])
- store2.refresh()
+ asyncio.run(store2.refresh())
assert cap2 == [], "restart must NEVER re-broadcast the existing backlog"
# And a genuinely new item after the restart still broadcasts once.
adapter2.set_batch(["A", "B", "C", "D", "E"])
- store2.refresh()
+ asyncio.run(store2.refresh())
assert _emitted_ids(cap2) == ["E"]
@@ -135,9 +136,9 @@ def test_stable_key_prevents_reemit_when_batch_reorders():
# The same real-world items in a different order are NOT "newly received".
store, adapter, captured = _make_store()
adapter.set_batch(["A", "B", "C"])
- store.refresh() # seed
+ asyncio.run(store.refresh()) # seed
adapter.set_batch(["C", "A", "B"]) # reordered, same items
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "reordering the same items emits nothing"
@@ -147,12 +148,12 @@ def test_disabled_for_days_then_backlog_is_not_broadcast():
store, adapter, captured = _make_store()
backlog = [f"evt{i}" for i in range(200)]
adapter.set_batch(backlog)
- store.refresh() # first poll after re-enable
+ asyncio.run(store.refresh()) # first poll after re-enable
assert captured == [], "a days-old backlog is seeded silently, never sent"
# Only a truly new arrival afterward is announced.
adapter.set_batch(backlog + ["fresh"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["fresh"]
@@ -290,11 +291,11 @@ def test_persistent_preseed_known_suppressed_new_emitted():
assert len(store._seen["wzdx"]) == 5
adapter.set_batch(known)
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "all 5 are durably-known → zero broadcast"
adapter.set_batch(known + ["z_new"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["z_new"], "only the not-in-table id broadcasts"
@@ -307,9 +308,9 @@ def test_persistent_preseed_cross_tick_staging_no_leak():
store, captured = _build_store(_GENERIC_NAME, adapter)
adapter.set_batch(["A"])
- store.refresh() # tick 1: only A present
+ asyncio.run(store.refresh()) # tick 1: only A present
adapter.set_batch(["A", "B"])
- store.refresh() # tick 2: B appears (backlog)
+ asyncio.run(store.refresh()) # tick 2: B appears (backlog)
assert captured == [], "B is durably-known — must NOT leak on a later tick"
# CONTROL: identical staging but NO durable rows → B leaks (proves the
@@ -325,11 +326,11 @@ def test_persistent_preseed_cross_tick_staging_no_leak():
# Re-point ctrl events to a fresh source with no durable rows.
for e in ctrl._batch:
e["source"] = "wzdx_ctrl"
- store2.refresh()
+ asyncio.run(store2.refresh())
ctrl.set_batch(["A", "B"])
for e in ctrl._batch:
e["source"] = "wzdx_ctrl"
- store2.refresh()
+ asyncio.run(store2.refresh())
assert [e.title for e in cap2] == ["B"], "without a durable record, B leaks"
@@ -343,11 +344,11 @@ def test_incremental_empty_first_tick_then_only_new_broadcasts():
store, captured = _build_store(_GENERIC_NAME, adapter)
adapter.set_batch([]) # empty first tick
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "empty tick emits nothing"
adapter.set_batch(["A", "B", "C"]) # backlog A,B + new C
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["C"], "only the never-received C broadcasts"
@@ -360,18 +361,18 @@ def test_restart_against_same_persistent_db_never_rebroadcasts():
a1 = _FakeWZDx()
store1, cap1 = _build_store(_GENERIC_NAME, a1)
a1.set_batch(backlog)
- store1.refresh()
+ asyncio.run(store1.refresh())
assert cap1 == [], "process 1: durable backlog is silent"
# RESTART: brand-new store, same persistent DB → pre-seed reloads.
a2 = _FakeWZDx()
store2, cap2 = _build_store(_GENERIC_NAME, a2)
a2.set_batch(backlog)
- store2.refresh()
+ asyncio.run(store2.refresh())
assert cap2 == [], "restart must NEVER re-broadcast the durable backlog"
a2.set_batch(backlog + ["E"])
- store2.refresh()
+ asyncio.run(store2.refresh())
assert _emitted_ids(cap2) == ["E"], "a genuinely-new item still broadcasts once"
@@ -385,11 +386,11 @@ def test_persistent_preseed_quake_by_event_id():
assert len(store._seen["usgs_quake"]) == 2
adapter.set_batch(["us1000aaaa", "us1000bbbb"])
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "both quakes already received → zero broadcast"
adapter.set_batch(["us1000aaaa", "us1000bbbb", "us1000cccc"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["us1000cccc"], "only the new quake broadcasts"
@@ -402,11 +403,11 @@ def test_no_durable_rows_falls_back_to_silent_first_poll():
assert "wzdx" not in store._seeded, "0 durable rows → not pre-marked seeded"
adapter.set_batch(["A", "B"])
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "first non-empty poll on a fresh DB is silent"
adapter.set_batch(["A", "B", "C"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["C"]
@@ -486,9 +487,9 @@ def test_persistent_preseed_roads511_by_external_id():
assert len(store._seen["511"]) == 4
adapter.set_batch(known)
- store.refresh()
+ asyncio.run(store.refresh())
assert captured == [], "all 4 durably-known 511 rows → zero broadcast"
adapter.set_batch(known + ["511_99"])
- store.refresh()
+ asyncio.run(store.refresh())
assert _emitted_ids(captured) == ["511_99"], "only the not-in-table id broadcasts"
diff --git a/work/tests/test_store_wzdx_persist.py b/work/tests/test_store_wzdx_persist.py
index 13422ee..4e60a97 100644
--- a/work/tests/test_store_wzdx_persist.py
+++ b/work/tests/test_store_wzdx_persist.py
@@ -20,6 +20,7 @@ adapter whose per-poll coalesced set we control, then assert directly against
traffic_events AND against the bus (nothing must ever be dispatched).
"""
from __future__ import annotations
+import asyncio
from meshai.env.store import EnvironmentalStore
from meshai.config import EnvironmentalConfig
@@ -147,7 +148,7 @@ def test_first_poll_persists_current_set_and_broadcasts_nothing():
store, captured = _build_store(adapter)
adapter.set_zones(ZONES3)
- store.refresh() # first (cold-start) poll
+ asyncio.run(store.refresh()) # first (cold-start) poll
rows = _wzdx_rows()
exts = {r["external_id"] for r in rows}
@@ -171,7 +172,7 @@ def test_columns_match_summary_and_dm_queries():
adapter = _FakeWZDx()
store, _ = _build_store(adapter)
adapter.set_zones([ZONES3[1]]) # the full_closure I-84 zone
- store.refresh()
+ asyncio.run(store.refresh())
r = _wzdx_rows()[0]
assert r["road"] == "I-84"
@@ -188,12 +189,12 @@ def test_subsequent_poll_reconciles_removed_zone():
adapter = _FakeWZDx()
store, captured = _build_store(adapter)
adapter.set_zones(ZONES3)
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 3
# Next poll: US-20 dropped out; I-84 + ID-55 remain.
adapter.set_zones([ZONES3[1], ZONES3[2]])
- store.refresh()
+ asyncio.run(store.refresh())
exts = {r["external_id"] for r in _wzdx_rows()}
assert exts == {ZONES3[1]["ext"], ZONES3[2]["ext"]}, (
@@ -207,11 +208,11 @@ def test_empty_or_failed_fetch_does_not_wipe_existing_rows():
adapter = _FakeWZDx()
store, _ = _build_store(adapter)
adapter.set_zones(ZONES3)
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 3
adapter.set_raw([]) # empty/failed poll
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 3, (
"an empty fetch must NEVER wipe the existing active set")
@@ -225,7 +226,7 @@ def test_upsert_preserves_first_seen_at_and_refreshes_end_at():
z = dict(ZONES3[0]); z["end_at"] = 1000
adapter.set_zones([z])
- store.refresh()
+ asyncio.run(store.refresh())
r1 = _wzdx_rows()[0]
first_seen = r1["first_seen_at"]
assert r1["end_at"] == 1000
@@ -233,7 +234,7 @@ def test_upsert_preserves_first_seen_at_and_refreshes_end_at():
# Same zone reappears with a LATER end_at.
z2 = dict(ZONES3[0]); z2["end_at"] = 5000
adapter.set_zones([z2])
- store.refresh()
+ asyncio.run(store.refresh())
r2 = _wzdx_rows()[0]
assert r2["first_seen_at"] == first_seen, "first_seen_at must be preserved"
assert r2["end_at"] == 5000, "end_at must refresh from the feed"
@@ -254,7 +255,7 @@ def test_expiry_end_at_preserved_for_not_expired_filter():
"sub_type": "x", "impact": "partial", "end_at": now - 10_000}, # expired
]
adapter.set_zones(zones)
- store.refresh()
+ asyncio.run(store.refresh())
# All 3 persisted (ingest does not itself drop expired rows) ...
assert len(_wzdx_rows()) == 3
@@ -269,7 +270,7 @@ def test_id_less_zone_is_skipped_not_fatal():
store, _ = _build_store(adapter)
good = ZONES3[0]
adapter.set_zones([good])
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 1
# Poll with the good zone plus an id-less junk event.
@@ -277,7 +278,7 @@ def test_id_less_zone_is_skipped_not_fatal():
junk = {"source": "wzdx", "event_id": None, "external_id": None,
"lat": 5.0, "lon": 5.0, "normalized": {}, "fetched_at": 0}
adapter._batch.append(junk)
- store.refresh()
+ asyncio.run(store.refresh())
rows = _wzdx_rows()
assert {r["external_id"] for r in rows} == {good["ext"]}, (
@@ -296,7 +297,7 @@ def test_bulk_current_set_persists_all_like_the_real_127():
for i in range(127)
]
adapter.set_zones(zones)
- store.refresh()
+ asyncio.run(store.refresh())
assert len(_wzdx_rows()) == 127
assert _summary_visible_count(now=0) == 127, (
From bdbc2afe7f07c735b9c0ffff0fa493389f32978e Mon Sep 17 00:00:00 2001
From: Matt Johnson
Date: Sun, 2 Aug 2026 04:10:20 +0000
Subject: [PATCH 7/9] MeshCore reconnect persistence: implement 0=unlimited
max-reconnect-attempts sentinel
connect() now translates a configured meshcore_max_reconnect_attempts of 0
(or <=0) into an effectively-unbounded count before handing it to the
meshcore library's ConnectionManager, so its retry loop never
permanently exhausts. config.py's comment already documented \"0 =
unlimited\" but that sentinel was never actually implemented -- literal 0
meant zero attempts, and the shipped default of 5 (at ~1s/attempt) gave up
after ~5s with no external supervisor to retry again, leaving MeshCore dead
until a manual container restart. Also flips the repo default from 5 to 0
so fresh deploys get unlimited retries without extra config.
Proven via a 60s forced-outage auto-recovery test: the link recovers from
any-length vnode/radio outage instead of giving up after ~5s.
Co-Authored-By: Claude Opus 4.8
---
work/meshai/config.py | 2 +-
work/meshai/transport/meshcore_transport.py | 33 +++++++++++++++++++++
2 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/work/meshai/config.py b/work/meshai/config.py
index 8e1dab6..35e7c56 100644
--- a/work/meshai/config.py
+++ b/work/meshai/config.py
@@ -51,7 +51,7 @@ class ConnectionConfig:
meshcore_host: str = "" # pyMC companion frame server host
meshcore_port: int = 5050 # pyMC companion frame server port
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
- meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
+ meshcore_max_reconnect_attempts: int = 0 # max reconnect attempts (0 = unlimited)
meshcore_advert_interval_seconds: int = 86400 # periodic self-advert interval, 24h (0 = disabled)
# MeshCore connection type: tcp | serial | ble (default tcp for back-compat)
meshcore_conn_type: str = "tcp"
diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py
index 6d15bba..221684b 100644
--- a/work/meshai/transport/meshcore_transport.py
+++ b/work/meshai/transport/meshcore_transport.py
@@ -43,6 +43,29 @@ _TELEMETRY_MAX_FAILURES = 3
# inside the 300s reaper window with margin to spare.
_KEEPALIVE_INTERVAL_SECONDS = 120
+# --- Reconnect persistence ("0 = unlimited" sentinel) ----------------------
+# config.py documents meshcore_max_reconnect_attempts as "0 = unlimited", but
+# that sentinel was never implemented here — the value was passed straight
+# through to the meshcore lib's ConnectionManager, whose retry loop is
+# `while self._reconnect_attempts < self.max_reconnect_attempts`. Taken
+# literally, 0 means ZERO attempts (immediate give-up), the opposite of
+# "unlimited", and any small bounded value (the shipped default is 5, at the
+# lib's flat 1s-per-attempt cadence) exhausts after ~5 seconds and then the
+# link stays down PERMANENTLY — there is no external supervisor for MeshCore
+# (see main.py's watchdog guard: "MeshCoreTransport manages its own
+# reconnect via the meshcore lib's auto_reconnect parameter"), so nothing
+# ever notices and retries again after that. A radio/vnode bounce longer
+# than ~5s (e.g. the 2026-08-02 device-perm heal test) killed MeshCore for
+# good until a manual container restart.
+#
+# Fix: honor the documented sentinel for real. connect() below translates a
+# configured 0 into this effectively-unbounded count, so the lib's own
+# proven-safe retry loop (still local TCP only, still ~1 attempt/sec, still
+# WITHOUT re-sending the connect-time self-advert — see
+# _post_reconnect_setup_async) just keeps going until the vnode/radio comes
+# back, no matter how long the outage lasts.
+_MC_RECONNECT_ATTEMPTS_UNLIMITED = 2_147_483_647
+
# Numeric Cayenne-LPP type id → decoded field name. Ids not in this map are
# passed through as ``lpp_`` so nothing is silently dropped.
_LPP_ID_TO_FIELD = {
@@ -1976,6 +1999,16 @@ class MeshCoreTransport(MeshTransport):
ble_address = getattr(self.config, "meshcore_ble_address", "")
auto_reconnect = getattr(self.config, "meshcore_auto_reconnect", True)
max_attempts = getattr(self.config, "meshcore_max_reconnect_attempts", 5)
+ if max_attempts <= 0:
+ # Documented sentinel (config.py: "0 = unlimited") — see
+ # _MC_RECONNECT_ATTEMPTS_UNLIMITED's docstring for why this was
+ # never actually unlimited before and why translating it here is
+ # the fix.
+ logger.info(
+ "MeshCoreTransport: meshcore_max_reconnect_attempts=%s (unlimited) -> %d",
+ max_attempts, _MC_RECONNECT_ATTEMPTS_UNLIMITED,
+ )
+ max_attempts = _MC_RECONNECT_ATTEMPTS_UNLIMITED
# Human-readable target for logging — from the same descriptor that
# self_info() reports, so the log and the API never disagree.
From 3961f7ec04b5872b6c143c8a9d7d4155cff80afc Mon Sep 17 00:00:00 2001
From: Matt Johnson
Date: Sun, 16 Aug 2026 02:58:00 +0000
Subject: [PATCH 8/9] fix: honour town_anchors.enabled in alert
anchor-resolution queries
resolve_anchor() (notifications/formatters/_anchor.py) and
_location_anchor() (env/fire_render.py) selected all town_anchors rows
regardless of the enabled flag, so a disabled anchor could still be
used in outbound alert text -- only the dashboard/curation routes are
meant to see disabled rows. Add AND enabled = 1 to both queries.
No-op today: all 186 live town_anchors rows are enabled=1. Matters the
next time someone disables an anchor from the curation UI.
Also updates the traffic_last/0003.json wzdx golden literal in
test_incident_refactor.py and adds TestAnchorResolve::
test_disabled_anchor_excluded, which forces the Photon fallback to
miss and asserts a disabled-only DB row is not selected.
---
work/meshai/env/fire_render.py | 2 +-
.../notifications/formatters/_anchor.py | 2 +-
work/tests/test_incident_refactor.py | 35 ++++++++++++++++++-
3 files changed, 36 insertions(+), 3 deletions(-)
diff --git a/work/meshai/env/fire_render.py b/work/meshai/env/fire_render.py
index 04028fb..3bd0f27 100644
--- a/work/meshai/env/fire_render.py
+++ b/work/meshai/env/fire_render.py
@@ -248,7 +248,7 @@ def _location_anchor(n: dict) -> str:
try:
from meshai.persistence import get_db
rows = get_db().execute(
- "SELECT name, lat, lon FROM town_anchors WHERE lat IS NOT NULL AND lon IS NOT NULL"
+ "SELECT name, lat, lon FROM town_anchors WHERE lat IS NOT NULL AND lon IS NOT NULL AND enabled = 1"
).fetchall()
best = None
best_d = float("inf")
diff --git a/work/meshai/notifications/formatters/_anchor.py b/work/meshai/notifications/formatters/_anchor.py
index 10960e1..d6fb7ee 100644
--- a/work/meshai/notifications/formatters/_anchor.py
+++ b/work/meshai/notifications/formatters/_anchor.py
@@ -96,7 +96,7 @@ def resolve_anchor(
from meshai.persistence import get_db
rows = get_db().execute(
"SELECT name, lat, lon FROM town_anchors "
- "WHERE lat IS NOT NULL AND lon IS NOT NULL"
+ "WHERE lat IS NOT NULL AND lon IS NOT NULL AND enabled = 1"
).fetchall()
best = None
best_d = float("inf")
diff --git a/work/tests/test_incident_refactor.py b/work/tests/test_incident_refactor.py
index 8f6d1ca..3bb82c2 100644
--- a/work/tests/test_incident_refactor.py
+++ b/work/tests/test_incident_refactor.py
@@ -188,7 +188,11 @@ class TestWorkZoneGolden:
_GOLDEN = {
"0002.json": "🚧 US-91, near Chubbuck: southbound, road construction, ends Aug 17",
- "0003.json": "🚧 US-95, near Wilder: southbound, ends Jul 19",
+ # "wilder" was added to the town_anchors seed by the seed-list sync
+ # (Fix 2), so the DB-anchor step now wins over the live Photon
+ # geocode this golden was originally captured against; the DB row's
+ # coords round to 1 mi S instead of Photon's sub-mile "near".
+ "0003.json": "🚧 US-95, 1 mi S of Wilder: southbound, ends Jul 19",
}
# Captured from the deleted normalizer's normalize() + _n_to_canonical_workzone()
@@ -434,6 +438,35 @@ class TestAnchorResolve:
assert resolve_anchor(None, -116.2, max_mi=50.0) is None
assert resolve_anchor(43.6, None, max_mi=50.0) is None
+ def test_disabled_anchor_excluded(self, monkeypatch):
+ """A disabled=0 town_anchors row must not be selected, even when it is
+ the closest row within max_mi — the enabled flag is a hard exclude."""
+ import time as _time
+ from meshai.persistence import get_db
+ from meshai.notifications.formatters._anchor import resolve_anchor
+ from meshai import geo
+
+ # Force the Photon fallback to a known miss so a non-None result can
+ # only come from the (wrongly-included) disabled DB row.
+ monkeypatch.setattr(
+ geo, "nearest_town",
+ lambda lat, lon, max_distance_mi=50.0: None,
+ )
+
+ conn = get_db()
+ # Clear seeded anchors so only our controlled (disabled) row exists.
+ conn.execute("DELETE FROM town_anchors")
+ conn.execute(
+ "INSERT INTO town_anchors(name, lat, lon, state, enabled, updated_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ ("disabledville", -33.8688, 151.2093, "NSW", 0, _time.time()), # Sydney
+ )
+
+ # Event right next to the disabled row; Photon fallback is forced to
+ # miss → None confirms the DB step excluded the disabled row.
+ result = resolve_anchor(-33.870, 151.210, max_mi=50.0)
+ assert result is None
+
# ── 4. Schema conformance ────────────────────────────────────────────────────
From 522458f194bdc53d4a0202f120c951123315cd67 Mon Sep 17 00:00:00 2001
From: Matt Johnson
Date: Sun, 16 Aug 2026 02:58:11 +0000
Subject: [PATCH 9/9] fix: sync _TOWN_ANCHORS_SEED with the production
town_anchors table
_TOWN_ANCHORS_SEED covered only 29 hand-picked towns, while the live
CT108 town_anchors table (GUI-curated since) has grown to 186. Any
fresh deploy (fresh volume, or the pre-v19 sqlite path) would seed
only the original 29 and silently lose anchor-resolution coverage for
the other 157 real, already-in-production towns.
Regenerated the dict from a live dump of CT108's town_anchors
(name, lat, lon, state; all 186 rows confirmed enabled=1), alphabetized,
same dict-of-dicts shape and column-aligned brace formatting as before
(alignment column widened to fit the longest name, "mountain home
afb"). seed_town_anchors() is unchanged and still INSERT OR IGNORE, so
it stays idempotent against the existing production rows.
Expanding the seed changes which town resolves as "nearest" for a few
existing test fixtures whose incident/work-zone coordinates are
genuinely closer to a newly-added real town (e.g. Oakley, Wilder) than
to the old 29-town subset's nearest match -- those goldens and
assertions are updated to the new (correct) nearest-town result.
test_api_post_add_town's probe town is renamed from the now-real
"Bellevue" to a fictitious "Testopolis" to avoid a duplicate-name 400.
---
work/meshai/persistence/curation.py | 215 ++++++++++++++++++++++++----
work/tests/test_curation.py | 9 +-
work/tests/test_fire_refactor.py | 5 +-
work/tests/test_wfigs_handler.py | 15 +-
4 files changed, 206 insertions(+), 38 deletions(-)
diff --git a/work/meshai/persistence/curation.py b/work/meshai/persistence/curation.py
index 0c69430..84978dd 100644
--- a/work/meshai/persistence/curation.py
+++ b/work/meshai/persistence/curation.py
@@ -85,35 +85,192 @@ _GAUGE_SITES_SEED: dict[str, dict[str, Any]] = {
# Idaho + neighbor towns, originally from a hardcoded _TOWN_COORDS table in
# the (since-deleted) Central-envelope adapter-normalizer module.
_TOWN_ANCHORS_SEED: dict[str, dict[str, Any]] = {
- "boise": {"lat": 43.6150, "lon": -116.2023, "state": "ID"},
- "meridian": {"lat": 43.6121, "lon": -116.3915, "state": "ID"},
- "nampa": {"lat": 43.5407, "lon": -116.5635, "state": "ID"},
- "caldwell": {"lat": 43.6629, "lon": -116.6874, "state": "ID"},
- "idaho falls": {"lat": 43.4666, "lon": -112.0340, "state": "ID"},
- "pocatello": {"lat": 42.8713, "lon": -112.4455, "state": "ID"},
- "twin falls": {"lat": 42.5630, "lon": -114.4609, "state": "ID"},
- "coeur d'alene": {"lat": 47.6777, "lon": -116.7805, "state": "ID"},
- "lewiston": {"lat": 46.4165, "lon": -117.0177, "state": "ID"},
- "moscow": {"lat": 46.7324, "lon": -117.0002, "state": "ID"},
- "sandpoint": {"lat": 48.2766, "lon": -116.5535, "state": "ID"},
- "post falls": {"lat": 47.7180, "lon": -116.9516, "state": "ID"},
- "hayden": {"lat": 47.7660, "lon": -116.7866, "state": "ID"},
- "rathdrum": {"lat": 47.8121, "lon": -116.8950, "state": "ID"},
- "plummer": {"lat": 47.3344, "lon": -116.8856, "state": "ID"},
- "kellogg": {"lat": 47.5380, "lon": -116.1352, "state": "ID"},
- "bonners ferry": {"lat": 48.6914, "lon": -116.3181, "state": "ID"},
- "rexburg": {"lat": 43.8260, "lon": -111.7897, "state": "ID"},
- "blackfoot": {"lat": 43.1905, "lon": -112.3447, "state": "ID"},
- "burley": {"lat": 42.5360, "lon": -113.7928, "state": "ID"},
- "jerome": {"lat": 42.7252, "lon": -114.5187, "state": "ID"},
- "mountain home": {"lat": 43.1330, "lon": -115.6912, "state": "ID"},
- "stanley": {"lat": 44.2160, "lon": -114.9311, "state": "ID"},
- "salmon": {"lat": 45.1758, "lon": -113.8957, "state": "ID"},
- "mccall": {"lat": 44.9111, "lon": -116.0987, "state": "ID"},
- "weiser": {"lat": 44.2510, "lon": -116.9690, "state": "ID"},
- "soda springs": {"lat": 42.6543, "lon": -111.6047, "state": "ID"},
- "preston": {"lat": 42.0963, "lon": -111.8766, "state": "ID"},
- "montpelier": {"lat": 42.3232, "lon": -111.2980, "state": "ID"},
+ "aberdeen": {"lat": 42.944098, "lon": -112.838381, "state": "ID"},
+ "albion": {"lat": 42.409808, "lon": -113.580438, "state": "ID"},
+ "american falls": {"lat": 42.782846, "lon": -112.854211, "state": "ID"},
+ "ammon": {"lat": 43.474999, "lon": -111.959631, "state": "ID"},
+ "arbon valley": {"lat": 42.88763, "lon": -112.589386, "state": "ID"},
+ "arco": {"lat": 43.631893, "lon": -113.301033, "state": "ID"},
+ "arimo": {"lat": 42.560385, "lon": -112.172927, "state": "ID"},
+ "ashton": {"lat": 44.073332, "lon": -111.448311, "state": "ID"},
+ "athol": {"lat": 47.947065, "lon": -116.707958, "state": "ID"},
+ "avimor": {"lat": 43.776181, "lon": -116.257108, "state": "ID"},
+ "bancroft": {"lat": 42.720241, "lon": -111.88301, "state": "ID"},
+ "basalt": {"lat": 43.314443, "lon": -112.165044, "state": "ID"},
+ "bellevue": {"lat": 43.467894, "lon": -114.254955, "state": "ID"},
+ "bennington": {"lat": 42.382259, "lon": -111.32098, "state": "ID"},
+ "blackfoot": {"lat": 43.1905, "lon": -112.3447, "state": "ID"},
+ "blanchard": {"lat": 48.0137, "lon": -116.996503, "state": "ID"},
+ "bliss": {"lat": 42.924284, "lon": -114.947516, "state": "ID"},
+ "boise": {"lat": 43.615, "lon": -116.2023, "state": "ID"},
+ "bonners ferry": {"lat": 48.6914, "lon": -116.3181, "state": "ID"},
+ "buhl": {"lat": 42.598362, "lon": -114.759536, "state": "ID"},
+ "burley": {"lat": 42.536, "lon": -113.7928, "state": "ID"},
+ "caldwell": {"lat": 43.6629, "lon": -116.6874, "state": "ID"},
+ "cambridge": {"lat": 44.571748, "lon": -116.678101, "state": "ID"},
+ "carey": {"lat": 43.312011, "lon": -113.941008, "state": "ID"},
+ "cascade": {"lat": 44.508761, "lon": -116.043627, "state": "ID"},
+ "castleford": {"lat": 42.520569, "lon": -114.871806, "state": "ID"},
+ "challis": {"lat": 44.505779, "lon": -114.228184, "state": "ID"},
+ "chubbuck": {"lat": 42.926182, "lon": -112.462537, "state": "ID"},
+ "clark fork": {"lat": 48.148019, "lon": -116.172988, "state": "ID"},
+ "clifton": {"lat": 42.187286, "lon": -112.004596, "state": "ID"},
+ "coeur d'alene": {"lat": 47.6777, "lon": -116.7805, "state": "ID"},
+ "cottonwood": {"lat": 46.051045, "lon": -116.349751, "state": "ID"},
+ "council": {"lat": 44.733195, "lon": -116.436837, "state": "ID"},
+ "craigmont": {"lat": 46.24174, "lon": -116.471344, "state": "ID"},
+ "culdesac": {"lat": 46.374896, "lon": -116.670097, "state": "ID"},
+ "dalton gardens": {"lat": 47.733412, "lon": -116.767873, "state": "ID"},
+ "dayton": {"lat": 42.111246, "lon": -111.984615, "state": "ID"},
+ "deary": {"lat": 46.800586, "lon": -116.557368, "state": "ID"},
+ "declo": {"lat": 42.519599, "lon": -113.628732, "state": "ID"},
+ "dietrich": {"lat": 42.912796, "lon": -114.266289, "state": "ID"},
+ "donnelly": {"lat": 44.733391, "lon": -116.086821, "state": "ID"},
+ "dover": {"lat": 48.259156, "lon": -116.609843, "state": "ID"},
+ "downey": {"lat": 42.428828, "lon": -112.123303, "state": "ID"},
+ "driggs": {"lat": 43.729865, "lon": -111.104319, "state": "ID"},
+ "dubois": {"lat": 44.171161, "lon": -112.228278, "state": "ID"},
+ "eagle": {"lat": 43.693423, "lon": -116.345989, "state": "ID"},
+ "east hope": {"lat": 48.240895, "lon": -116.28941, "state": "ID"},
+ "eden": {"lat": 42.605309, "lon": -114.209087, "state": "ID"},
+ "emmett": {"lat": 43.869228, "lon": -116.491336, "state": "ID"},
+ "fairfield": {"lat": 43.348045, "lon": -114.800826, "state": "ID"},
+ "fernwood": {"lat": 47.115099, "lon": -116.386484, "state": "ID"},
+ "filer": {"lat": 42.56789, "lon": -114.611471, "state": "ID"},
+ "firth": {"lat": 43.305788, "lon": -112.183454, "state": "ID"},
+ "fort hall": {"lat": 43.014547, "lon": -112.45787, "state": "ID"},
+ "franklin": {"lat": 42.009503, "lon": -111.802183, "state": "ID"},
+ "fruitland": {"lat": 44.020412, "lon": -116.922109, "state": "ID"},
+ "garden city": {"lat": 43.668297, "lon": -116.294389, "state": "ID"},
+ "garden valley": {"lat": 44.083404, "lon": -115.958464, "state": "ID"},
+ "genesee": {"lat": 46.551608, "lon": -116.928389, "state": "ID"},
+ "georgetown": {"lat": 42.479083, "lon": -111.363497, "state": "ID"},
+ "glenns ferry": {"lat": 42.949908, "lon": -115.308185, "state": "ID"},
+ "gooding": {"lat": 42.937048, "lon": -114.713188, "state": "ID"},
+ "grace": {"lat": 42.575182, "lon": -111.729771, "state": "ID"},
+ "grand view": {"lat": 42.985123, "lon": -116.09368, "state": "ID"},
+ "grangeville": {"lat": 45.925826, "lon": -116.121916, "state": "ID"},
+ "greenleaf": {"lat": 43.672607, "lon": -116.821443, "state": "ID"},
+ "groveland": {"lat": 43.223483, "lon": -112.37547, "state": "ID"},
+ "hagerman": {"lat": 42.816016, "lon": -114.897681, "state": "ID"},
+ "hailey": {"lat": 43.512674, "lon": -114.299499, "state": "ID"},
+ "hammett": {"lat": 42.944114, "lon": -115.465521, "state": "ID"},
+ "hansen": {"lat": 42.531365, "lon": -114.301177, "state": "ID"},
+ "harrison": {"lat": 47.469582, "lon": -116.808336, "state": "ID"},
+ "hauser": {"lat": 47.773694, "lon": -117.008, "state": "ID"},
+ "hayden": {"lat": 47.766, "lon": -116.7866, "state": "ID"},
+ "hayden lake": {"lat": 47.76446, "lon": -116.756097, "state": "ID"},
+ "hazelton": {"lat": 42.59548, "lon": -114.136614, "state": "ID"},
+ "heyburn": {"lat": 42.559982, "lon": -113.762067, "state": "ID"},
+ "hidden springs": {"lat": 43.716541, "lon": -116.259415, "state": "ID"},
+ "hollister": {"lat": 42.352911, "lon": -114.583846, "state": "ID"},
+ "homedale": {"lat": 43.615937, "lon": -116.939029, "state": "ID"},
+ "horseshoe bend": {"lat": 43.916085, "lon": -116.199236, "state": "ID"},
+ "idaho city": {"lat": 43.827844, "lon": -115.830474, "state": "ID"},
+ "idaho falls": {"lat": 43.4666, "lon": -112.034, "state": "ID"},
+ "inkom": {"lat": 42.796548, "lon": -112.254625, "state": "ID"},
+ "iona": {"lat": 43.527007, "lon": -111.930914, "state": "ID"},
+ "irwin": {"lat": 43.403359, "lon": -111.279513, "state": "ID"},
+ "jerome": {"lat": 42.7252, "lon": -114.5187, "state": "ID"},
+ "juliaetta": {"lat": 46.574737, "lon": -116.70808, "state": "ID"},
+ "kamiah": {"lat": 46.226796, "lon": -116.028303, "state": "ID"},
+ "kellogg": {"lat": 47.538, "lon": -116.1352, "state": "ID"},
+ "kendrick": {"lat": 46.614183, "lon": -116.661272, "state": "ID"},
+ "ketchum": {"lat": 43.687718, "lon": -114.380069, "state": "ID"},
+ "kimberly": {"lat": 42.534299, "lon": -114.369931, "state": "ID"},
+ "kooskia": {"lat": 46.141687, "lon": -115.973646, "state": "ID"},
+ "kootenai": {"lat": 48.311831, "lon": -116.517128, "state": "ID"},
+ "kuna": {"lat": 43.469607, "lon": -116.424153, "state": "ID"},
+ "laclede": {"lat": 48.167129, "lon": -116.751565, "state": "ID"},
+ "lapwai": {"lat": 46.403715, "lon": -116.804223, "state": "ID"},
+ "lava hot springs": {"lat": 42.620107, "lon": -112.009902, "state": "ID"},
+ "lewiston": {"lat": 46.4165, "lon": -117.0177, "state": "ID"},
+ "lewisville": {"lat": 43.695208, "lon": -112.013232, "state": "ID"},
+ "lincoln": {"lat": 43.51825, "lon": -111.969215, "state": "ID"},
+ "mackay": {"lat": 43.911996, "lon": -113.612728, "state": "ID"},
+ "malad city": {"lat": 42.189909, "lon": -112.249688, "state": "ID"},
+ "marsing": {"lat": 43.54636, "lon": -116.810422, "state": "ID"},
+ "mccall": {"lat": 44.9111, "lon": -116.0987, "state": "ID"},
+ "mccammon": {"lat": 42.648236, "lon": -112.189394, "state": "ID"},
+ "melba": {"lat": 43.373633, "lon": -116.531933, "state": "ID"},
+ "menan": {"lat": 43.721791, "lon": -111.992353, "state": "ID"},
+ "meridian": {"lat": 43.6121, "lon": -116.3915, "state": "ID"},
+ "middleton": {"lat": 43.711593, "lon": -116.615008, "state": "ID"},
+ "montpelier": {"lat": 42.3232, "lon": -111.298, "state": "ID"},
+ "moreland": {"lat": 43.21948, "lon": -112.437772, "state": "ID"},
+ "moscow": {"lat": 46.7324, "lon": -117.0002, "state": "ID"},
+ "mountain home": {"lat": 43.133, "lon": -115.6912, "state": "ID"},
+ "mountain home afb": {"lat": 43.049186, "lon": -115.86586, "state": "ID"},
+ "moyie springs": {"lat": 48.724746, "lon": -116.195421, "state": "ID"},
+ "mud lake": {"lat": 43.842855, "lon": -112.479504, "state": "ID"},
+ "mullan": {"lat": 47.468759, "lon": -115.796351, "state": "ID"},
+ "nampa": {"lat": 43.5407, "lon": -116.5635, "state": "ID"},
+ "new meadows": {"lat": 44.971335, "lon": -116.285195, "state": "ID"},
+ "new plymouth": {"lat": 43.970417, "lon": -116.818781, "state": "ID"},
+ "newdale": {"lat": 43.886385, "lon": -111.603888, "state": "ID"},
+ "nezperce": {"lat": 46.233582, "lon": -116.241418, "state": "ID"},
+ "notus": {"lat": 43.726863, "lon": -116.800432, "state": "ID"},
+ "oakley": {"lat": 42.24206, "lon": -113.883058, "state": "ID"},
+ "oldtown": {"lat": 48.182488, "lon": -117.018005, "state": "ID"},
+ "orofino": {"lat": 46.484943, "lon": -116.253027, "state": "ID"},
+ "osburn": {"lat": 47.505731, "lon": -116.000709, "state": "ID"},
+ "paris": {"lat": 42.227978, "lon": -111.402424, "state": "ID"},
+ "parker": {"lat": 43.958405, "lon": -111.759206, "state": "ID"},
+ "parma": {"lat": 43.786284, "lon": -116.942491, "state": "ID"},
+ "paul": {"lat": 42.605496, "lon": -113.784487, "state": "ID"},
+ "payette": {"lat": 44.080093, "lon": -116.926852, "state": "ID"},
+ "pierce": {"lat": 46.495335, "lon": -115.803292, "state": "ID"},
+ "pinehurst": {"lat": 47.536314, "lon": -116.231746, "state": "ID"},
+ "plummer": {"lat": 47.3344, "lon": -116.8856, "state": "ID"},
+ "pocatello": {"lat": 42.8713, "lon": -112.4455, "state": "ID"},
+ "ponderay": {"lat": 48.30478, "lon": -116.536645, "state": "ID"},
+ "post falls": {"lat": 47.718, "lon": -116.9516, "state": "ID"},
+ "potlatch": {"lat": 46.923493, "lon": -116.897713, "state": "ID"},
+ "preston": {"lat": 42.0963, "lon": -111.8766, "state": "ID"},
+ "priest river": {"lat": 48.18336, "lon": -116.884354, "state": "ID"},
+ "rathdrum": {"lat": 47.8121, "lon": -116.895, "state": "ID"},
+ "rexburg": {"lat": 43.826, "lon": -111.7897, "state": "ID"},
+ "richfield": {"lat": 43.05164, "lon": -114.155942, "state": "ID"},
+ "rigby": {"lat": 43.673587, "lon": -111.913525, "state": "ID"},
+ "riggins": {"lat": 45.420591, "lon": -116.317636, "state": "ID"},
+ "ririe": {"lat": 43.632494, "lon": -111.771717, "state": "ID"},
+ "riverside": {"lat": 43.196554, "lon": -112.435625, "state": "ID"},
+ "roberts": {"lat": 43.720488, "lon": -112.128868, "state": "ID"},
+ "robie creek": {"lat": 43.667649, "lon": -116.015203, "state": "ID"},
+ "rockford": {"lat": 43.189235, "lon": -112.530618, "state": "ID"},
+ "rockford bay": {"lat": 47.508637, "lon": -116.886536, "state": "ID"},
+ "rockland": {"lat": 42.573157, "lon": -112.87453, "state": "ID"},
+ "rupert": {"lat": 42.618936, "lon": -113.673967, "state": "ID"},
+ "salmon": {"lat": 45.1758, "lon": -113.8957, "state": "ID"},
+ "sandpoint": {"lat": 48.2766, "lon": -116.5535, "state": "ID"},
+ "shelley": {"lat": 43.379538, "lon": -112.126098, "state": "ID"},
+ "shoshone": {"lat": 42.936185, "lon": -114.404747, "state": "ID"},
+ "silverton": {"lat": 47.495681, "lon": -115.960513, "state": "ID"},
+ "smelterville": {"lat": 47.542423, "lon": -116.177448, "state": "ID"},
+ "soda springs": {"lat": 42.6543, "lon": -111.6047, "state": "ID"},
+ "spirit lake": {"lat": 47.965799, "lon": -116.869831, "state": "ID"},
+ "st. anthony": {"lat": 43.964839, "lon": -111.685049, "state": "ID"},
+ "st. maries": {"lat": 47.314589, "lon": -116.572235, "state": "ID"},
+ "stanley": {"lat": 44.216, "lon": -114.9311, "state": "ID"},
+ "star": {"lat": 43.702788, "lon": -116.491025, "state": "ID"},
+ "sugar city": {"lat": 43.87582, "lon": -111.751032, "state": "ID"},
+ "sun valley": {"lat": 43.683852, "lon": -114.334203, "state": "ID"},
+ "swan valley": {"lat": 43.442575, "lon": -111.324544, "state": "ID"},
+ "teton": {"lat": 43.887773, "lon": -111.672254, "state": "ID"},
+ "tetonia": {"lat": 43.814578, "lon": -111.158664, "state": "ID"},
+ "troy": {"lat": 46.737981, "lon": -116.773154, "state": "ID"},
+ "twin falls": {"lat": 42.563, "lon": -114.4609, "state": "ID"},
+ "tyhee": {"lat": 42.954001, "lon": -112.456199, "state": "ID"},
+ "ucon": {"lat": 43.593538, "lon": -111.959358, "state": "ID"},
+ "victor": {"lat": 43.60155, "lon": -111.110822, "state": "ID"},
+ "wallace": {"lat": 47.473578, "lon": -115.922542, "state": "ID"},
+ "weippe": {"lat": 46.37825, "lon": -115.938844, "state": "ID"},
+ "weiser": {"lat": 44.251, "lon": -116.969, "state": "ID"},
+ "wendell": {"lat": 42.77468, "lon": -114.70296, "state": "ID"},
+ "weston": {"lat": 42.036209, "lon": -111.977799, "state": "ID"},
+ "wilder": {"lat": 43.678388, "lon": -116.907585, "state": "ID"},
+ "winchester": {"lat": 46.240812, "lon": -116.624137, "state": "ID"},
+ "worley": {"lat": 47.400533, "lon": -116.919254, "state": "ID"},
}
diff --git a/work/tests/test_curation.py b/work/tests/test_curation.py
index ebf3f48..47faf40 100644
--- a/work/tests/test_curation.py
+++ b/work/tests/test_curation.py
@@ -183,13 +183,16 @@ def test_api_list_towns(client):
def test_api_post_add_town(client):
+ # A clearly-fictitious name, not a real Idaho town -- avoids colliding
+ # with the town_anchors seed (real curated Idaho/neighbor towns; a
+ # UNIQUE(name) constraint 400s on a duplicate insert).
r = client.post("/api/town-anchors", json={
- "name": "Bellevue", "lat": 43.4670, "lon": -114.2557, "state": "ID",
+ "name": "Testopolis", "lat": 43.4670, "lon": -114.2557, "state": "ID",
})
assert r.status_code == 200
- assert r.json()["name"] == "bellevue"
+ assert r.json()["name"] == "testopolis"
invalidate_curation_cache()
- coord = lookup_town_anchor("bellevue")
+ coord = lookup_town_anchor("testopolis")
assert coord is not None
diff --git a/work/tests/test_fire_refactor.py b/work/tests/test_fire_refactor.py
index 8b1deea..da09eb3 100644
--- a/work/tests/test_fire_refactor.py
+++ b/work/tests/test_fire_refactor.py
@@ -217,7 +217,10 @@ class TestFormatterGolden:
# Golden literal (captured from the live fire_format all-clear branch;
# this is the SAME format string handle_wfigs used to build inline
# before its removal -- see notifications/formatters/fire.py::_render_allclear).
- assert new_wire == "✅ Cache Peak Fire — contained & closed\n1,847 ac | 23% contained | 24 mi S of Burley"
+ # "oakley" is nearer to 42.197,-113.710 than "burley" and is now in
+ # the town_anchors seed after the seed-list sync (Fix 2), so it wins
+ # the anchor resolution instead of the previously-nearest seeded town.
+ assert new_wire == "✅ Cache Peak Fire — contained & closed\n1,847 ac | 23% contained | 9 mi E of Oakley"
# ─────────────────────────────────────────────────────────────────────────────
diff --git a/work/tests/test_wfigs_handler.py b/work/tests/test_wfigs_handler.py
index c81728b..7fb2a21 100644
--- a/work/tests/test_wfigs_handler.py
+++ b/work/tests/test_wfigs_handler.py
@@ -357,8 +357,10 @@ def test_k_anchor_falls_to_nearest_town(monkeypatch, mem_db):
county="Cassia")
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
- # Resolves anchor via town_anchors table (Burley @ 42.536, -113.793)
- assert "Burley" in wire
+ # Resolves anchor via town_anchors table (Oakley @ 42.24206, -113.883058
+ # -- now the nearest seeded anchor to the incident's 42.197,-113.710
+ # after the full-list seed sync; Burley is farther away)
+ assert "Oakley" in wire
def test_k_anchor_falls_to_landclass(monkeypatch, mem_db):
@@ -372,7 +374,8 @@ def test_k_anchor_falls_to_landclass(monkeypatch, mem_db):
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
# Resolves nearest town from town_anchors table, overriding landclass
- assert "Burley" in wire
+ # (Oakley is nearest to 42.197,-113.710 after the full-list seed sync)
+ assert "Oakley" in wire
def test_k_anchor_falls_to_county(monkeypatch, mem_db):
@@ -385,7 +388,8 @@ def test_k_anchor_falls_to_county(monkeypatch, mem_db):
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
# Resolves nearest town from town_anchors table
- assert "Burley" in wire
+ # (Oakley is nearest to 42.197,-113.710 after the full-list seed sync)
+ assert "Oakley" in wire
def test_k_anchor_nearest_town_under_one_mile_says_near(monkeypatch, mem_db):
@@ -398,7 +402,8 @@ def test_k_anchor_nearest_town_under_one_mile_says_near(monkeypatch, mem_db):
n = _normalize_wfigs(env)
wire = _wfigs_render(n, prefix="New")
# Anchor resolved via town_anchors; exact format depends on distance
- assert "Burley" in wire
+ # (Oakley is nearest to 42.197,-113.710 after the full-list seed sync)
+ assert "Oakley" in wire
# ============================================================================