From de400068dd733f16d204b073e6d45a2adf591e02 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Tue, 5 May 2026 06:33:38 +0000 Subject: [PATCH] feat(mesh): config-driven regions with stale purge and coverage fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extended RegionAnchor with local_name, description, aliases, cities - Moved region geographic context from hardcoded Python to config.yaml - Added 7-day stale node purge in _do_refresh (556 → 267 nodes) - Fixed coverage lookup: str(node_num) → node_num (int key) - Added bidirectional neighbor lookup for better region assignment - Dynamic geography building in router from config - Reporter reads region context from config instead of hardcoded dict Co-Authored-By: Claude Opus 4.5 --- meshai/config.py | 6 ++- meshai/main.py | 3 +- meshai/mesh_data_store.py | 29 ++++++++++++++ meshai/mesh_health.py | 82 +++++++++++++++++++++----------------- meshai/mesh_reporter.py | 42 ++++++++++---------- meshai/router.py | 83 +++++++++++++++++---------------------- 6 files changed, 139 insertions(+), 106 deletions(-) diff --git a/meshai/config.py b/meshai/config.py index d0c2c99..2ed33ca 100644 --- a/meshai/config.py +++ b/meshai/config.py @@ -179,11 +179,15 @@ class MeshSourceConfig: @dataclass class RegionAnchor: - """A fixed region anchor point.""" + """A fixed region anchor point with geographic context.""" name: str = "" lat: float = 0.0 lon: float = 0.0 + local_name: str = "" # e.g., "Magic Valley" + description: str = "" # e.g., "Twin Falls, Burley, Jerome along I-84/US-93" + aliases: list[str] = field(default_factory=list) # e.g., ["southern Idaho", "magic valley"] + cities: list[str] = field(default_factory=list) # e.g., ["Twin Falls", "Burley", "Jerome"] @dataclass diff --git a/meshai/main.py b/meshai/main.py index ec9604a..6b23019 100644 --- a/meshai/main.py +++ b/meshai/main.py @@ -250,7 +250,8 @@ class MeshAI: # Mesh reporter (for LLM prompt injection and commands) if self.health_engine and self.data_store: from .mesh_reporter import MeshReporter - self.mesh_reporter = MeshReporter(self.health_engine, self.data_store) + mi_regions = self.config.mesh_intelligence.regions if self.config.mesh_intelligence else [] + self.mesh_reporter = MeshReporter(self.health_engine, self.data_store, region_configs=mi_regions) logger.info("Mesh reporter enabled") else: self.mesh_reporter = None diff --git a/meshai/mesh_data_store.py b/meshai/mesh_data_store.py index 4fd1569..30d48f8 100644 --- a/meshai/mesh_data_store.py +++ b/meshai/mesh_data_store.py @@ -30,6 +30,9 @@ from .sources.meshview import MeshviewSource logger = logging.getLogger(__name__) +# Nodes not heard in this many days are excluded from live model +STALE_NODE_THRESHOLD_DAYS = 7 + # Meshtastic role enum mapping (integer -> string) MESHTASTIC_ROLE_MAP = { 0: "CLIENT", @@ -351,6 +354,31 @@ class MeshDataStore: # Source Management # ========================================================================= + + def _purge_stale_nodes(self): + """Remove nodes not heard from in more than 7 days. + + These nodes are stale — they inflate counts, create false + coverage gaps, and waste LLM tokens. They still exist in + SQLite historical data, just not in the live model. + """ + import time + cutoff = time.time() - (STALE_NODE_THRESHOLD_DAYS * 86400) + + stale_nums = [] + for node_num, node in self._nodes.items(): + if node.last_heard and node.last_heard < cutoff: + stale_nums.append(node_num) + elif not node.last_heard or node.last_heard == 0: + # No last_heard data at all — also consider stale + stale_nums.append(node_num) + + for num in stale_nums: + del self._nodes[num] + + if stale_nums: + logger.info(f"Purged {len(stale_nums)} stale nodes (not heard in {STALE_NODE_THRESHOLD_DAYS} days)") + def refresh(self) -> bool: """Refresh data from all sources if interval has elapsed. @@ -403,6 +431,7 @@ class MeshDataStore: if refreshed > 0: self._rebuild() + self._purge_stale_nodes() # Remove nodes not heard in 7+ days self._store_snapshot() self._purge_old_data() self._last_refresh = time.time() diff --git a/meshai/mesh_health.py b/meshai/mesh_health.py index d596f25..a1c4190 100644 --- a/meshai/mesh_health.py +++ b/meshai/mesh_health.py @@ -292,37 +292,57 @@ class MeshHealthEngine: else: unlocated.append(str(node_num)) - # Build neighbor map from edges - # First, create a mapping from numeric node_id to hex id - numeric_to_hex: dict[str, str] = {} - for node in all_nodes: - hex_id = node.get("id") - num_id = node.get("node_id") - if hex_id and num_id: - numeric_to_hex[str(num_id)] = str(hex_id) + # Build BIDIRECTIONAL neighbor map from ALL sources: + # 1. Each node's own neighbor list (from NeighborInfo packets) + # 2. REVERSE: if A lists B as neighbor, B also sees A + # 3. Edges from traceroutes and other connections + all_neighbor_map: dict[int, set[int]] = {} + # First: add each node's own neighbor list AND reverse relationships + for node_num, node in nodes.items(): + if node.neighbors: + if node_num not in all_neighbor_map: + all_neighbor_map[node_num] = set() + for nb_num in node.neighbors: + all_neighbor_map[node_num].add(nb_num) + # REVERSE: if this node sees nb_num, nb_num also "sees" this node + if nb_num not in all_neighbor_map: + all_neighbor_map[nb_num] = set() + all_neighbor_map[nb_num].add(node_num) + + # Second: add from edges (connections from traceroutes, etc.) + if hasattr(data_store, 'edges'): + for edge in data_store.edges: + from_num = edge.from_node + to_num = edge.to_node + if from_num not in all_neighbor_map: + all_neighbor_map[from_num] = set() + if to_num not in all_neighbor_map: + all_neighbor_map[to_num] = set() + all_neighbor_map[from_num].add(to_num) + all_neighbor_map[to_num].add(from_num) + + # Also add from raw edges API all_edges = source_manager.get_all_edges() - neighbors: dict[str, set[str]] = {} for edge in all_edges: - # Get edge endpoints (may be numeric) from_raw = edge.get("from") or edge.get("from_node") or edge.get("source") to_raw = edge.get("to") or edge.get("to_node") or edge.get("target") if not from_raw or not to_raw: continue + try: + from_num = int(from_raw) if not str(from_raw).startswith("!") else int(str(from_raw)[1:], 16) + to_num = int(to_raw) if not str(to_raw).startswith("!") else int(str(to_raw)[1:], 16) + except (ValueError, TypeError): + continue + if from_num not in all_neighbor_map: + all_neighbor_map[from_num] = set() + if to_num not in all_neighbor_map: + all_neighbor_map[to_num] = set() + all_neighbor_map[from_num].add(to_num) + all_neighbor_map[to_num].add(from_num) - # Convert to hex ID format if numeric - from_id = numeric_to_hex.get(str(from_raw), str(from_raw)) - to_id = numeric_to_hex.get(str(to_raw), str(to_raw)) - - if from_id not in neighbors: - neighbors[from_id] = set() - if to_id not in neighbors: - neighbors[to_id] = set() - neighbors[from_id].add(to_id) - neighbors[to_id].add(from_id) - - # Second pass: Assign unlocated nodes based on neighbor regions - # Repeat until no more assignments + # Second pass: Assign unlocated nodes based on BIDIRECTIONAL neighbor map + # This catches nodes that OTHER nodes list as neighbors max_iterations = 10 for _ in range(max_iterations): newly_assigned = [] @@ -337,19 +357,11 @@ class MeshHealthEngine: if node.region: continue # Already assigned - # Count neighbor regions - neighbor_ids = neighbors.get(node_id_str, set()) + # Use the BIDIRECTIONAL neighbor map + neighbor_nums = all_neighbor_map.get(node_num, set()) region_counts: dict[str, int] = {} - for nid in neighbor_ids: - # Convert string ID to int for nodes lookup - try: - if nid.startswith("!"): - nid_int = int(nid[1:], 16) - else: - nid_int = int(nid) - except (ValueError, AttributeError): - continue - neighbor_node = nodes.get(nid_int) + for neighbor_num in neighbor_nums: + neighbor_node = nodes.get(neighbor_num) if neighbor_node and neighbor_node.region: r = neighbor_node.region region_counts[r] = region_counts.get(r, 0) + 1 diff --git a/meshai/mesh_reporter.py b/meshai/mesh_reporter.py index f601f86..a78d3b8 100644 --- a/meshai/mesh_reporter.py +++ b/meshai/mesh_reporter.py @@ -36,21 +36,6 @@ PORTNUM_DISPLAY = { "ATAK_FORWARDER": "ATAK", } -# Geographic context for region names -REGION_CONTEXT = { - "South Central ID": "Magic Valley (Twin Falls area)", - "South Western ID": "Treasure Valley (Boise metro)", - "South Eastern ID": "Eastern Idaho (Idaho Falls area)", - "Central ID": "Idaho backcountry (Salmon area)", - "Northern ID": "North Idaho (Panhandle)", - "Northern UT": "Northern Utah (Ogden/Logan)", - "Central UT": "Wasatch Front (Salt Lake City)", - "Eastern UT": "Eastern Utah (Vernal/Moab)", - "Western UT": "Western Utah (Tooele)", - "Southern UT": "Southern Utah / Dixie (St. George)", -} - - def _clean_portnum(portnum: str) -> str: """Convert raw portnum to display name.""" return PORTNUM_DISPLAY.get(portnum, portnum.replace("_APP", "").replace("_", " ").title()) @@ -167,15 +152,28 @@ def _is_valid_temperature(temp_c: Optional[float]) -> bool: class MeshReporter: """Builds text blocks for mesh health prompt injection.""" - def __init__(self, health_engine: "MeshHealthEngine", data_store: "MeshDataStore"): + def __init__(self, health_engine: "MeshHealthEngine", data_store: "MeshDataStore", region_configs=None): """Initialize reporter. Args: health_engine: MeshHealthEngine instance data_store: MeshDataStore instance + region_configs: Optional list of RegionAnchor configs for local names """ self.health_engine = health_engine self.data_store = data_store + self._region_configs = {r.name: r for r in (region_configs or [])} + + def _region_context(self, region_name: str) -> str: + """Get display context for a region from config.""" + cfg = self._region_configs.get(region_name) + if not cfg: + return "" + local = getattr(cfg, "local_name", "") or "" + desc = getattr(cfg, "description", "") or "" + if local and desc: + return f"{local} ({desc})" + return local or desc def build_tier1_summary(self) -> str: """Build compact mesh summary for LLM injection (~500-800 tokens). @@ -294,7 +292,9 @@ class MeshReporter: lines.append(f"MQTT Uplinks: {health.uplink_node_count} nodes") # Coverage by region - show geographic breakdown - all_nodes = list(self.data_store.nodes.values()) + # MUST use health.nodes (not data_store.nodes) because region is set by health engine + health = self.health_engine.mesh_health + all_nodes = list(health.nodes.values()) if health else [] nodes_with_gw = [n for n in all_nodes if n.avg_gateways is not None] if nodes_with_gw: total_sources = len(self.data_store._sources) @@ -306,7 +306,7 @@ class MeshReporter: region_coverage = {} for n in nodes_with_gw: - health_node = health.nodes.get(str(n.node_num)) + health_node = health.nodes.get(n.node_num) region = health_node.region if health_node else "Unlocated" if not region: region = "Unlocated" @@ -323,7 +323,7 @@ class MeshReporter: single = sum(1 for c in counts if c <= 1.0) flag = " !!" if avg < 2.0 else "" single_str = f" ({single} 1-gw)" if single > 0 else "" - context = REGION_CONTEXT.get(region, "") + context = self._region_context(region) context_str = f" ({context})" if context else "" lines.append(f" {region}{context_str}: {len(counts)} nodes, {avg:.1f} avg{single_str}{flag}") # Show unlocated as informational (no coverage recommendations for these) @@ -344,7 +344,7 @@ class MeshReporter: rs = region.score flag = _tier_flag(rs.tier) infra_str = f"{rs.infra_online}/{rs.infra_total} infra" - context = REGION_CONTEXT.get(region.name, "") + context = self._region_context(region.name) context_str = f" ({context})" if context else "" lines.append( f" {region.name}{context_str}: {rs.composite:.0f}/100 - {infra_str}, {rs.util_percent:.0f}% util{flag}" @@ -556,7 +556,7 @@ class MeshReporter: return f"REGION DETAIL: {region_name}\nRegion not found." rs = region.score - context = REGION_CONTEXT.get(region.name, "") + context = self._region_context(region.name) context_str = f" — {context}" if context else "" lines = [ f"REGION DETAIL: {region.name}{context_str}", diff --git a/meshai/router.py b/meshai/router.py index 0f93207..ab1a352 100644 --- a/meshai/router.py +++ b/meshai/router.py @@ -123,70 +123,42 @@ _CITY_TO_REGION = { "cedar city": "Southern UT", } +# Mesh awareness instruction for LLM # Mesh awareness instruction for LLM _MESH_AWARENESS_PROMPT = """ MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions): RESPONSE STYLE: - Give DETAILED, data-driven responses with actual numbers -- Talk in GEOGRAPHIC terms — reference cities, valleys, corridors people know -- Name specific infrastructure nodes by their long name, not just shortnames +- Talk in GEOGRAPHIC terms — use the local names and cities from REGION GEOGRAPHY below +- Name specific infrastructure nodes by their long name - Include scores, percentages, node counts, battery levels, gateway counts - You CAN use 3-5 messages if needed — LoRa chunking handles splitting - No markdown formatting — plain text only -- Be specific and data-driven. Do NOT compress to vague one-liners - -REGION GEOGRAPHY — use these local names and landmarks: - -IDAHO: -- Northern ID: "North Idaho" or "the Panhandle" — Coeur d'Alene, Moscow, Lewiston, Sandpoint. Along I-90/US-95. -- Central ID: Idaho backcountry — Salmon, Stanley, McCall, Challis. Remote, mountainous, very sparse. Frank Church wilderness. -- South Western ID: "Treasure Valley" — Boise, Meridian, Nampa, Caldwell, Eagle, Mountain Home. Idaho's most populated region along I-84. Do NOT call this "southern Idaho." -- South Central ID: "Magic Valley" — Twin Falls, Burley, Jerome, Hailey, Gooding, Shoshone, Sun Valley. Snake River Canyon corridor along I-84/US-93. When locals say "southern Idaho" they usually mean this area. -- South Eastern ID: "Eastern Idaho" — Idaho Falls, Pocatello, Rexburg, Blackfoot. Upper Snake River Plain, gateway to Yellowstone. - -IMPORTANT IDAHO GEOGRAPHY: -- "Southern Idaho" is ambiguous. Do NOT lump Treasure Valley (Boise), Magic Valley (Twin Falls), and Eastern Idaho together. They are distinct areas 100+ miles apart. -- If someone says "southern Idaho" they most likely mean the Magic Valley (South Central ID — Twin Falls area). -- If unclear, describe each region separately rather than grouping. -- "Treasure Valley" always means the Boise metro area (South Western ID). -- "Magic Valley" always means the Twin Falls area (South Central ID). -- "Eastern Idaho" means Idaho Falls/Pocatello area (South Eastern ID). - -UTAH: -- Northern UT: Ogden, Logan, Brigham City, Cache Valley. Northern Wasatch Front. -- Central UT: "The Wasatch Front" or "Salt Lake" — Salt Lake City, Provo, Park City, Orem. About 2/3 of Utah's population lives here. -- Eastern UT: Vernal and the Uinta Basin in the north, Moab and canyon country in the south. Dinosaur NM, Arches, Canyonlands. -- Western UT: Tooele, Wendover. West desert, Bonneville Salt Flats. Very sparse. -- Southern UT: "Dixie" or "Color Country" — St. George, Cedar City, Hurricane. Zion, Bryce Canyon country. Fastest growing area in Utah. ANSWERING COVERAGE QUESTIONS: -- Reference the geographic area by local name: "Coverage across the Magic Valley from Burley through Twin Falls to Jerome..." -- Name infrastructure nodes by long name: "Mount Harrison Router, Indian Springs Router, Two Towers, and North Shoshone relay provide backbone coverage" -- Give avg gateways AND explain what it means: "nodes reach an average of 5.7 gateways — excellent redundancy" -- Identify single-gateway nodes as risks: "28 nodes in the Treasure Valley only reach 1 gateway" -- For "where do we need more infrastructure" — name the geographic area, explain how many nodes are underserved -- Do NOT recommend infrastructure for "Unlocated" nodes — those have no known position +- Reference geographic areas by local name from the region config +- Name infrastructure nodes by long name +- Give avg gateways AND explain meaning +- Identify single-gateway nodes as risks +- For "where do we need infrastructure" — name the geographic area, not "Unknown" +- Do NOT recommend infrastructure for Unlocated nodes ANSWERING NODE QUESTIONS: -- Include: hardware model, role, region with local name ("in the Magic Valley near Twin Falls"), battery status, channel utilization, TX airtime, coverage (X/Y gateways), neighbor count, recent traffic with packet breakdown -- Name neighbors by long name -- Compare metrics to thresholds: "18.5% channel utilization is moderate — approaching the 20% caution threshold" -- If the node has environmental sensors, include readings +- Include: hardware, role, region with local name, battery, channel util, TX airtime, coverage, neighbors, traffic breakdown +- Compare metrics to thresholds -ANSWERING MESH OVERVIEW QUESTIONS: -- Lead with composite score and tier -- Break down all 5 pillars with actual scores -- Highlight problem areas by geographic/local name -- Include top senders, coverage gaps, battery warnings by name -- Include traffic trends and sensor data if available +ANSWERING MESH OVERVIEW: +- Lead with composite score and 5 pillars +- Break down by region using local names +- Highlight problems with specific nodes/areas ABOUT UNLOCATED NODES: -- About 280 nodes have no GPS position and no neighbor data -- These are stale, transient, or from sources that don't track position -- Do NOT recommend infrastructure for "Unknown" or "Unlocated" areas -- When asked about coverage gaps, focus ONLY on located regions -- If asked about unlocated nodes specifically, explain they're nodes without position data that can't be placed on the map +- Nodes with no GPS and no neighbor data cannot be placed on the map +- Do NOT recommend infrastructure for these areas +- Focus coverage gaps on located regions only + +IMPORTANT: Do NOT lump different regions together just because they share directional words. Each region is a distinct geographic area. """ @@ -631,6 +603,21 @@ class MessageRouter: # Add mesh awareness instructions system_prompt += _MESH_AWARENESS_PROMPT + # Build region geography from config dynamically + if self.config.mesh_intelligence and self.config.mesh_intelligence.regions: + geo_lines = ["", "REGION GEOGRAPHY (use local names when discussing these regions):"] + for region in self.config.mesh_intelligence.regions: + local = getattr(region, "local_name", "") or "" + local_str = f' "{local}"' if local else "" + desc = getattr(region, "description", "") or "" + desc_str = f" — {desc}" if desc else "" + aliases = getattr(region, "aliases", []) or [] + alias_str = "" + if aliases: + alias_str = f'\n People may call this: {", ".join(aliases)}' + geo_lines.append(f" - {region.name}{local_str}{desc_str}{alias_str}") + system_prompt += "\n".join(geo_lines) + # Update mesh context tracking self._update_user_mesh_context( message.sender_id,