From 4183abe755535afa40274d668b22557e9a6d3032 Mon Sep 17 00:00:00 2001 From: K7ZVX Date: Tue, 5 May 2026 05:12:45 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20DELETE=20NodeHealth=20=E2=80=94=20r?= =?UTF-8?q?eporter=20uses=20UnifiedNode=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeHealth is gone. MeshHealth.nodes is now dict[int, UnifiedNode]. Reporter reads all fields from UnifiedNode: coverage, environment, neighbors, hw_model — everything available without cross-referencing. This eliminates the entire category of field missing on NodeHealth bugs. Co-Authored-By: Claude Opus 4.5 --- meshai/mesh_health.py | 454 ++++++++++------------------------------ meshai/mesh_reporter.py | 171 ++++++++------- 2 files changed, 206 insertions(+), 419 deletions(-) diff --git a/meshai/mesh_health.py b/meshai/mesh_health.py index d3b58c6..d596f25 100644 --- a/meshai/mesh_health.py +++ b/meshai/mesh_health.py @@ -17,6 +17,7 @@ from .geo import ( get_cluster_center, haversine_distance, ) +from .mesh_models import UnifiedNode logger = logging.getLogger(__name__) @@ -95,90 +96,6 @@ class HealthScore: return "Critical" -@dataclass -class NodeHealth: - """Health data for a single node.""" - - node_id: str - short_name: str = "" - long_name: str = "" - role: str = "" - hw_model: str = "" - is_infrastructure: bool = False - last_seen: float = 0.0 - is_online: bool = True - - # Location - latitude: Optional[float] = None - longitude: Optional[float] = None - region: str = "" - locality: str = "" - - # Metrics - packet_count_24h: int = 0 - text_packet_count_24h: int = 0 - position_packet_count_24h: int = 0 - telemetry_packet_count_24h: int = 0 - battery_percent: Optional[float] = None - voltage: Optional[float] = None - channel_utilization: Optional[float] = None # From device telemetry - air_util_tx: Optional[float] = None # From device telemetry - has_solar: bool = False - uplink_enabled: bool = False - neighbor_count: int = 0 - packets_sent_24h: int = 0 - - # Packet breakdown by portnum - packets_by_portnum: dict[str, int] = field(default_factory=dict) - - # Scores - score: HealthScore = field(default_factory=HealthScore) - - @property - def node_num(self) -> int: - """Convert node_id hex string to integer.""" - if self.node_id.startswith("!"): - return int(self.node_id[1:], 16) - return int(self.node_id, 16) - - @property - def non_text_packets(self) -> int: - """Non-text packets in 24h.""" - return self.packet_count_24h - self.text_packet_count_24h - - @property - def estimated_position_interval(self) -> Optional[float]: - """Estimate position broadcast interval in seconds.""" - if self.position_packet_count_24h > 0: - return 86400 / self.position_packet_count_24h - return None - - @property - def node_id_hex(self) -> str: - """Return node_id in hex format with ! prefix.""" - if self.node_id.startswith("!"): - return self.node_id - try: - return f"!{int(self.node_id):08x}" - except: - return self.node_id - - @property - def battery_trend(self) -> str: - """Return battery trend indicator.""" - return "" # Not tracked yet - - @property - def packets_by_type(self) -> dict: - """Alias for packets_by_portnum.""" - return self.packets_by_portnum - - @property - def predicted_depletion_hours(self) -> Optional[float]: - """Predict hours until battery depletion.""" - return None # Not tracked yet - - @dataclass class LocalityHealth: """Health data for a locality (sub-region cluster).""" @@ -208,7 +125,7 @@ class MeshHealth: regions: list[RegionHealth] = field(default_factory=list) unlocated_nodes: list[str] = field(default_factory=list) - nodes: dict[str, NodeHealth] = field(default_factory=dict) + nodes: dict[int, UnifiedNode] = field(default_factory=dict) score: HealthScore = field(default_factory=HealthScore) last_computed: float = 0.0 @@ -333,213 +250,25 @@ class MeshHealthEngine: # Track if we have packet data for utilization calculation has_packet_data = len(all_packets) > 0 - # Build node health records - # BUG 2 FIX: Use _node_num as the canonical key - nodes: dict[str, NodeHealth] = {} - for node in all_nodes: - # Use _node_num set by source manager (canonical Meshtastic node number) - node_num = node.get("_node_num") - if node_num is not None: - node_id = str(node_num) - else: - # Fallback for nodes without _node_num - node_id = node.get("nodeNum") or node.get("id") or node.get("nodeId") or node.get("num") - if not node_id: - continue - node_id = str(node_id) + # Use UnifiedNode objects directly from data_store - NO NodeHealth + nodes: dict[int, UnifiedNode] = {} + for node_num, unified in data_store.nodes.items(): + # Set is_infrastructure based on role + unified.is_infrastructure = str(unified.role).upper() in INFRASTRUCTURE_ROLES + # Set is_online based on last_heard + unified.is_online = unified.last_heard > offline_threshold if unified.last_heard else False + nodes[node_num] = unified - # Skip if we already have this node from another source - if node_id in nodes: - continue + # Skip all the old NodeHealth creation, telemetry, and packet parsing + # That data is already on UnifiedNode from MeshDataStore - # Extract fields (handle different API formats) - short_name = node.get("shortName") or node.get("short_name") or "" - long_name = node.get("longName") or node.get("long_name") or "" - role = node.get("role") or "" - hw_model = node.get("hwModel") or node.get("hw_model") or "" - - # Determine if infrastructure - is_infra = str(role).upper() in INFRASTRUCTURE_ROLES - - # Get position (handle different API formats) - lat = node.get("latitude") or node.get("lat") - lon = node.get("longitude") or node.get("lon") - # Handle nested position object - if lat is None and "position" in node: - pos = node["position"] - lat = pos.get("latitude") or pos.get("lat") - lon = pos.get("longitude") or pos.get("lon") - # Handle Meshview scaled integer format (last_lat/last_long) - if lat is None: - lat = node.get("last_lat") - lon = node.get("last_long") - # Meshview uses 1e7 scaling for GPS coordinates - if lat is not None and isinstance(lat, int) and abs(lat) > 1000: - lat = lat / 1e7 - if lon is not None and isinstance(lon, int) and abs(lon) > 1000: - lon = lon / 1e7 - - # Get last seen (handle different timestamp formats) - last_seen = node.get("lastHeard") or node.get("last_heard") or node.get("lastSeen") or 0 - # Handle Meshview microsecond timestamps - if not last_seen: - last_seen_us = node.get("last_seen_us") - if last_seen_us: - last_seen = last_seen_us / 1e6 # Convert microseconds to seconds - if isinstance(last_seen, str): - try: - from datetime import datetime - last_seen = datetime.fromisoformat(last_seen.replace("Z", "+00:00")).timestamp() - except: - last_seen = 0 - - is_online = last_seen > offline_threshold if last_seen else False - - nodes[node_id] = NodeHealth( - node_id=node_id, - short_name=short_name, - long_name=long_name, - role=role, - hw_model=hw_model, - is_infrastructure=is_infra, - last_seen=last_seen, - is_online=is_online, - latitude=lat, - longitude=lon, - ) - - # Add telemetry data - # BUG 4 & 5 FIX: Handle MeshMonitor telemetryType/value structure - for telem in all_telemetry: - # Get node number - try decimal first, then hex - node_num = telem.get("nodeNum") - if node_num is not None: - node_id = str(int(node_num)) - else: - node_hex = telem.get("nodeId") or telem.get("node_id") or "" - if isinstance(node_hex, str) and node_hex: - stripped = node_hex.lstrip("!") - try: - node_id = str(int(stripped, 16)) - except ValueError: - continue - else: - continue - - if node_id not in nodes: - continue - - node = nodes[node_id] - - # Handle MeshMonitor telemetryType/value structure - telem_type = (telem.get("telemetryType") or "").lower() - value = telem.get("value") - - if telem_type and value is not None: - try: - value = float(value) - except (ValueError, TypeError): - value = None - - if value is not None: - if telem_type in ("batterylevel", "battery_level", "battery"): - node.battery_percent = value - elif telem_type == "voltage": - node.voltage = value - elif telem_type in ("channelutilization", "channel_utilization"): - node.channel_utilization = value - elif telem_type in ("airutiltx", "air_util_tx"): - node.air_util_tx = value - elif telem_type in ("uplinkenabled", "uplink_enabled"): - node.uplink_enabled = bool(value) - - # Also try direct field access as fallback (for flat telemetry objects) - if node.battery_percent is None: - bat = telem.get("batteryLevel") or telem.get("battery_level") - if bat is not None: - try: - node.battery_percent = float(bat) - except (ValueError, TypeError): - pass - if node.voltage is None: - vol = telem.get("voltage") - if vol is not None: - try: - node.voltage = float(vol) - except (ValueError, TypeError): - pass - if node.channel_utilization is None: - ch_util = telem.get("channelUtilization") or telem.get("channel_utilization") - if ch_util is not None: - try: - node.channel_utilization = float(ch_util) - except (ValueError, TypeError): - pass - if node.air_util_tx is None: - air_tx = telem.get("airUtilTx") or telem.get("air_util_tx") - if air_tx is not None: - try: - node.air_util_tx = float(air_tx) - except (ValueError, TypeError): - pass - - # Check for uplink (MQTT) enabled - uplink = telem.get("uplinkEnabled") or telem.get("uplink_enabled") - if uplink: - node.uplink_enabled = True - - # Count packets per node (last 24h) with portnum breakdown - # BUG 3 FIX: Use correct MeshMonitor packet field names - twenty_four_hours_ago = now - 86400 - for pkt in all_packets: - pkt_time = pkt.get("timestamp") or pkt.get("rxTime") or 0 - if pkt_time < twenty_four_hours_ago: - continue - - # Extract from_node using multiple possible field names - from_raw = pkt.get("from_node") or pkt.get("from") or pkt.get("fromId") or pkt.get("from_node_id") - if from_raw is None: - continue - - # Normalize to canonical node number string - if isinstance(from_raw, int): - from_id = str(from_raw) - elif isinstance(from_raw, str): - # Could be hex like "!a1b2c3d4" or decimal string - stripped = from_raw.lstrip("!") - try: - from_id = str(int(stripped, 16)) - except ValueError: - if stripped.isdigit(): - from_id = stripped - else: - continue - else: - continue - - if from_id not in nodes: - continue - - nodes[from_id].packet_count_24h += 1 - - # Get portnum for breakdown - port_num = pkt.get("portnum_name") or pkt.get("portnum") or pkt.get("port_num") or "" - port_name = str(port_num).upper() - - # Track by portnum - if port_name: - nodes[from_id].packets_by_portnum[port_name] = \ - nodes[from_id].packets_by_portnum.get(port_name, 0) + 1 - - # Check if text message - if "TEXT" in port_name: - nodes[from_id].text_packet_count_24h += 1 - # Count position packets - elif "POSITION" in port_name: - nodes[from_id].position_packet_count_24h += 1 - # Count telemetry packets - elif "TELEMETRY" in port_name: - nodes[from_id].telemetry_packet_count_24h += 1 + # REMOVED: All the telemetry parsing loop + # REMOVED: All the packet counting loop + # Data is already available on UnifiedNode: + # - unified.battery_percent, voltage, channel_utilization, air_util_tx + # - unified.packets_sent_24h, text_messages_24h, packets_by_type + # - unified.uplink_enabled, neighbor_count, neighbors + # - unified.avg_gateways, deliverability_score # Initialize regions from anchors region_map: dict[str, RegionHealth] = {} @@ -552,16 +281,16 @@ class MeshHealthEngine: # Assign nodes to nearest region (first pass: GPS-based) unlocated = [] - for node in nodes.values(): + for node_num, node in nodes.items(): if node.latitude and node.longitude: region_name = self._find_nearest_region(node.latitude, node.longitude) if region_name and region_name in region_map: node.region = region_name - region_map[region_name].node_ids.append(node.node_id) + region_map[region_name].node_ids.append(str(node_num)) else: - unlocated.append(node.node_id) + unlocated.append(str(node_num)) else: - unlocated.append(node.node_id) + unlocated.append(str(node_num)) # Build neighbor map from edges # First, create a mapping from numeric node_id to hex id @@ -597,27 +326,40 @@ class MeshHealthEngine: max_iterations = 10 for _ in range(max_iterations): newly_assigned = [] - for node_id in unlocated: - if node_id not in nodes: + for node_id_str in unlocated: + try: + node_num = int(node_id_str) + except ValueError: continue - node = nodes[node_id] + if node_num not in nodes: + continue + node = nodes[node_num] if node.region: continue # Already assigned # Count neighbor regions - neighbor_ids = neighbors.get(node_id, set()) + neighbor_ids = neighbors.get(node_id_str, set()) region_counts: dict[str, int] = {} for nid in neighbor_ids: - if nid in nodes and nodes[nid].region: - r = nodes[nid].region + # 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) + if neighbor_node and neighbor_node.region: + r = neighbor_node.region region_counts[r] = region_counts.get(r, 0) + 1 if region_counts: # Assign to most common neighbor region best_region = max(region_counts, key=region_counts.get) node.region = best_region - region_map[best_region].node_ids.append(node_id) - newly_assigned.append(node_id) + region_map[best_region].node_ids.append(node_id_str) + newly_assigned.append(node_id_str) # Remove newly assigned from unlocated for nid in newly_assigned: @@ -634,11 +376,15 @@ class MeshHealthEngine: if not region.node_ids: continue - region_nodes = [ - {"id": nid, "latitude": nodes[nid].latitude, "longitude": nodes[nid].longitude} - for nid in region.node_ids - if nodes[nid].latitude and nodes[nid].longitude - ] + region_nodes = [] + for nid_str in region.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue + node = nodes.get(nid) + if node and node.latitude and node.longitude: + region_nodes.append({"id": nid_str, "latitude": node.latitude, "longitude": node.longitude}) if not region_nodes: continue @@ -665,7 +411,12 @@ class MeshHealthEngine: # Mark nodes with their locality for n in cluster: if n["id"] in nodes: - nodes[n["id"]].locality = locality.name + try: + loc_nid = int(n["id"]) + if loc_nid in nodes: + nodes[loc_nid].locality = locality.name + except (ValueError, TypeError): + pass # Compute scores at each level (pass packet data availability flag) self._compute_locality_scores(regions, nodes, has_packet_data) @@ -708,22 +459,8 @@ class MeshHealthEngine: self._mesh_health = mesh_health - # Sync health scores back to UnifiedNode objects - if data_store: - for node_id_str, node_health in nodes.items(): - try: - node_num = int(node_id_str) - unified = data_store.nodes.get(node_num) - if unified: - unified.is_infrastructure = node_health.is_infrastructure - unified.health_score = node_health.score.composite - unified.infra_score = node_health.score.infrastructure - unified.util_score = node_health.score.utilization - unified.coverage_score_node = node_health.score.coverage - unified.behavior_score = node_health.score.behavior - unified.power_score = node_health.score.power - except (ValueError, TypeError): - pass + # Health scores are computed for node groups/regions, not individual nodes + # UnifiedNode objects already have their individual scores set during compute # Log computation summary with data availability data_sources = [] @@ -748,30 +485,44 @@ class MeshHealthEngine: def _compute_locality_scores( self, regions: list[RegionHealth], - nodes: dict[str, NodeHealth], + nodes: dict[int, UnifiedNode], has_packet_data: bool = False, ) -> None: """Compute health scores for each locality.""" for region in regions: for locality in region.localities: - locality_nodes = [nodes[nid] for nid in locality.node_ids if nid in nodes] + locality_nodes = [] + for nid_str in locality.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue + if nid in nodes: + locality_nodes.append(nodes[nid]) locality.score = self._compute_node_group_score(locality_nodes, has_packet_data) def _compute_region_scores( self, regions: list[RegionHealth], - nodes: dict[str, NodeHealth], + nodes: dict[int, UnifiedNode], has_packet_data: bool = False, ) -> None: """Compute health scores for each region.""" for region in regions: - region_nodes = [nodes[nid] for nid in region.node_ids if nid in nodes] + region_nodes = [] + for nid_str in region.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue + if nid in nodes: + region_nodes.append(nodes[nid]) region.score = self._compute_node_group_score(region_nodes, has_packet_data) def _compute_mesh_score( self, regions: list[RegionHealth], - nodes: dict[str, NodeHealth], + nodes: dict[int, UnifiedNode], has_packet_data: bool = False, ) -> HealthScore: """Compute mesh-wide health score.""" @@ -780,13 +531,13 @@ class MeshHealthEngine: def _compute_node_group_score( self, - node_list: list[NodeHealth], + node_list: list[UnifiedNode], has_packet_data: bool = False, ) -> HealthScore: """Compute health score for a group of nodes. Args: - node_list: List of NodeHealth objects + node_list: List of UnifiedNode objects has_packet_data: Whether packet data is available for utilization calc Returns: @@ -808,7 +559,7 @@ class MeshHealthEngine: # Channel utilization (based on packet counts if available) # BUG 7 FIX: Use actual Meshtastic airtime calculation if has_packet_data: - total_non_text_packets = sum(n.non_text_packets for n in node_list) + total_non_text_packets = sum((n.packets_sent_24h - n.text_messages_24h) for n in node_list) # Average airtime per packet on MediumFast: ~200ms # Total available airtime per hour: 3,600,000ms # Utilization = (packets_per_hour * airtime_ms) / total_airtime_ms * 100 @@ -834,7 +585,7 @@ class MeshHealthEngine: util_score = 100.0 # Node behavior (flagged nodes) - flagged = [n for n in node_list if n.non_text_packets > self.packet_threshold] + flagged = [n for n in node_list if (n.packets_sent_24h - n.text_messages_24h) > self.packet_threshold] flagged_count = len(flagged) if flagged_count == 0: @@ -932,39 +683,54 @@ class MeshHealthEngine: return region return None - def get_node(self, node_id: str) -> Optional[NodeHealth]: - """Get a node by ID or short name.""" + def get_node(self, identifier: str) -> Optional[UnifiedNode]: + """Get a node by ID, name, or hex.""" if not self._mesh_health: return None - if node_id in self._mesh_health.nodes: - return self._mesh_health.nodes[node_id] + # Try as int (node_num) + try: + num = int(identifier) + if num in self._mesh_health.nodes: + return self._mesh_health.nodes[num] + except ValueError: + pass - node_id_lower = node_id.lower() + # Try shortname/longname + id_lower = identifier.lower().strip() for node in self._mesh_health.nodes.values(): - if node.short_name.lower() == node_id_lower: + if node.short_name and node.short_name.lower() == id_lower: return node - if node.long_name.lower() == node_id_lower: + if node.long_name and id_lower in node.long_name.lower(): return node + # Try hex + if identifier.startswith("!"): + try: + num = int(identifier[1:], 16) + if num in self._mesh_health.nodes: + return self._mesh_health.nodes[num] + except ValueError: + pass + return None - def get_infrastructure_nodes(self) -> list[NodeHealth]: + def get_infrastructure_nodes(self) -> list[UnifiedNode]: """Get all infrastructure nodes.""" if not self._mesh_health: return [] return [n for n in self._mesh_health.nodes.values() if n.is_infrastructure] - def get_flagged_nodes(self) -> list[NodeHealth]: + def get_flagged_nodes(self) -> list[UnifiedNode]: """Get nodes flagged for excessive packets.""" if not self._mesh_health: return [] return [ n for n in self._mesh_health.nodes.values() - if n.non_text_packets > self.packet_threshold + if (n.packets_sent_24h - n.text_messages_24h) > self.packet_threshold ] - def get_battery_warnings(self) -> list[NodeHealth]: + def get_battery_warnings(self) -> list[UnifiedNode]: """Get nodes with low battery.""" if not self._mesh_health: return [] diff --git a/meshai/mesh_reporter.py b/meshai/mesh_reporter.py index 28e0dce..34eec45 100644 --- a/meshai/mesh_reporter.py +++ b/meshai/mesh_reporter.py @@ -10,7 +10,8 @@ from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from .mesh_data_store import MeshDataStore - from .mesh_health import MeshHealthEngine, NodeHealth, RegionHealth + from .mesh_health import MeshHealthEngine, RegionHealth + from .mesh_models import UnifiedNode logger = logging.getLogger(__name__) @@ -499,18 +500,18 @@ class MeshReporter: flagged = self.health_engine.get_flagged_nodes() for node in flagged[:3]: threshold = self.health_engine.packet_threshold - ratio = node.non_text_packets / threshold - name = _node_display_name(node.long_name, node.short_name, node.node_id) + ratio = (node.packets_sent_24h - node.text_messages_24h) / threshold + name = _node_display_name(node.long_name, node.short_name, str(node.node_num)) issues.append( f"Node {name} sending " - f"{node.non_text_packets} non-text packets/24h ({ratio:.1f}x threshold)" + f"{(node.packets_sent_24h - node.text_messages_24h)} non-text packets/24h ({ratio:.1f}x threshold)" ) # Battery issues (skip USB-powered nodes) battery_warnings = self.health_engine.get_battery_warnings() for node in battery_warnings[:2]: if node.battery_percent is not None and node.battery_percent <= 100: - name = _node_display_name(node.long_name, node.short_name, node.node_id) + name = _node_display_name(node.long_name, node.short_name, str(node.node_num)) issues.append( f"Node {name} battery at {node.battery_percent:.0f}%" ) @@ -538,7 +539,11 @@ class MeshReporter: # Collect infrastructure nodes infra_nodes = [] - for nid in region.node_ids: + for nid_str in region.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue node = health.nodes.get(nid) if node and node.is_infrastructure: infra_nodes.append((nid, node)) @@ -546,7 +551,7 @@ class MeshReporter: # List infrastructure nodes with battery, packets, and utilization for nid, node in infra_nodes: status = "+" if node.is_online else "X" - age = _format_age(node.last_seen) + age = _format_age(node.last_heard) role = node.role or "ROUTER" hw = f", {node.hw_model}" if node.hw_model else "" @@ -559,14 +564,13 @@ class MeshReporter: metrics.append(f"seen {age}") if node.battery_percent is not None: metrics.append(f"bat {_format_battery(node.battery_percent, node.voltage)}") - if node.packet_count_24h > 0: - metrics.append(f"{node.packet_count_24h} pkts/24h") + if node.packets_sent_24h > 0: + metrics.append(f"{node.packets_sent_24h} pkts/24h") if node.channel_utilization is not None: metrics.append(f"util {node.channel_utilization:.1f}%") # Add neighbor count from unified node - unified_node = self.data_store.nodes.get(node.node_num) - if unified_node and unified_node.neighbor_count > 0: - metrics.append(f"{unified_node.neighbor_count} neighbors") + if node.neighbor_count > 0: + metrics.append(f"{node.neighbor_count} neighbors") line = f" {status} {name_str} - {', '.join(metrics)}" if not node.is_online: @@ -621,23 +625,31 @@ class MeshReporter: # Flagged nodes in this region flagged_in_region = [] - for nid in region.node_ids: + for nid_str in region.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue node = health.nodes.get(nid) - if node and node.non_text_packets > self.health_engine.packet_threshold: + if node and (node.packets_sent_24h - node.text_messages_24h) > self.health_engine.packet_threshold: flagged_in_region.append(node) if flagged_in_region: lines.append("") lines.append("Flagged Nodes (high packet senders):") for node in flagged_in_region[:5]: - name = _node_display_name(node.long_name, node.short_name, node.node_id) + name = _node_display_name(node.long_name, node.short_name, str(node.node_num)) lines.append( - f" {name}: {node.non_text_packets} non-text pkts/24h" + f" {name}: {(node.packets_sent_24h - node.text_messages_24h)} non-text pkts/24h" ) # Power warnings in this region (skip USB-powered) low_bat = [] - for nid in region.node_ids: + for nid_str in region.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue node = health.nodes.get(nid) if ( node @@ -710,7 +722,8 @@ class MeshReporter: return f"NODE DETAIL: {node_identifier}\nNode not found." # Get corresponding unified node from data store for historical data - unified = self.data_store.get_node(node.node_num) + # All fields now directly on node (UnifiedNode) + unified = node # Header with long name first display_name = _node_display_name(node.long_name, node.short_name, str(node.node_num)) @@ -725,35 +738,36 @@ class MeshReporter: if node.latitude and node.longitude: lines.append(f"Position: {node.latitude:.4f}, {node.longitude:.4f}") - age = _format_age(node.last_seen) + age = _format_age(node.last_heard) status = "Online" if node.is_online else "OFFLINE" lines.append(f"Last Seen: {age} ({status})") # Sources from unified node - if unified and unified.sources: + if node.sources: lines.append(f"Sources: {', '.join(unified.sources)}") # Traffic stats with historical data lines.append("") lines.append("Traffic History:") - lines.append(f" 24h: {node.packet_count_24h} pkts") + lines.append(f" 24h: {node.packets_sent_24h} pkts") if unified: lines.append(f" 48h: {unified.packets_sent_48h}") - lines.append(f" 7d: {unified.packets_sent_7d}") + lines.append(f" 7d: {node.packets_sent_7d}") lines.append(f" 14d: {unified.packets_sent_14d}") # Packet breakdown with clean portnum names - if node.packets_by_portnum: + if node.packets_by_type: lines.append("") lines.append("Packet Breakdown (24h):") for portnum, count in sorted( - node.packets_by_portnum.items(), key=lambda x: -x[1] + node.packets_by_type.items(), key=lambda x: -x[1] )[:5]: clean_name = _clean_portnum(portnum) lines.append(f" {clean_name}: {count}") # Estimated intervals - est_pos = node.estimated_position_interval + pos_count = node.packets_by_type.get("POSITION_APP", 0) + est_pos = 86400 / pos_count if pos_count > 0 else None if est_pos is not None: if est_pos < 60: interval_str = f"{int(est_pos)}s" @@ -797,21 +811,21 @@ class MeshReporter: lines.append(f" MQTT Uplink: {'Enabled' if node.uplink_enabled else 'Disabled'}") # Coverage - if unified and unified.avg_gateways is not None: + if node.avg_gateways is not None: total_gw = len(self.data_store._sources) - pct = (unified.avg_gateways / total_gw * 100) if total_gw > 0 else 0 - if unified.avg_gateways >= total_gw: + pct = (node.avg_gateways / total_gw * 100) if total_gw > 0 else 0 + if node.avg_gateways >= total_gw: status = "Full" - elif unified.avg_gateways >= 2: + elif node.avg_gateways >= 2: status = "Partial" else: status = "Single gateway - node goes dark if that gateway fails" - lines.append(f" Coverage: {unified.avg_gateways:.0f}/{total_gw} gateways ({pct:.0f}%) - {status}") + lines.append(f" Coverage: {node.avg_gateways:.0f}/{total_gw} gateways ({pct:.0f}%) - {status}") # Neighbors section - if unified and unified.neighbors: + if node.neighbors: lines.append("") - lines.append(f"Neighbors ({unified.neighbor_count}):") + lines.append(f"Neighbors ({node.neighbor_count}):") # Build edge lookup for signal quality edge_lookup = {} @@ -856,11 +870,11 @@ class MeshReporter: # Environment section (from unified node sensor data) if unified: env_lines = [] - if unified.temperature is not None: - temp_str = _format_temperature(unified.temperature) + if node.temperature is not None: + temp_str = _format_temperature(node.temperature) env_lines.append(f"Temp: {temp_str}") - if unified.humidity is not None: - env_lines.append(f"Humidity: {unified.humidity:.1f}%") + if node.humidity is not None: + env_lines.append(f"Humidity: {node.humidity:.1f}%") if unified.barometric_pressure is not None: env_lines.append(f"Pressure: {unified.barometric_pressure:.1f} hPa") if unified.gas_resistance is not None: @@ -870,15 +884,15 @@ class MeshReporter: env_lines.append(f"IAQ: {unified.iaq:.0f} ({iaq_label})") if unified.light_lux is not None: env_lines.append(f"Light: {unified.light_lux:.0f} lux") - if unified.wind_speed is not None: - env_lines.append(f"Wind: {unified.wind_speed:.1f} m/s") + if node.wind_speed is not None: + env_lines.append(f"Wind: {node.wind_speed:.1f} m/s") if unified.wind_direction is not None: env_lines.append(f"Wind Dir: {unified.wind_direction:.0f} deg") if unified.rainfall is not None: env_lines.append(f"Rainfall: {unified.rainfall:.1f} mm") - if unified.pm2_5 is not None: - aqi_label = "Good" if unified.pm2_5 < 12 else "Moderate" if unified.pm2_5 < 35 else "Unhealthy" - env_lines.append(f"PM2.5: {unified.pm2_5:.1f} ug/m3 ({aqi_label})") + if node.pm2_5 is not None: + aqi_label = "Good" if node.pm2_5 < 12 else "Moderate" if node.pm2_5 < 35 else "Unhealthy" + env_lines.append(f"PM2.5: {node.pm2_5:.1f} ug/m3 ({aqi_label})") if unified.pm10 is not None: env_lines.append(f"PM10: {unified.pm10:.1f} ug/m3") if unified.ext_voltage is not None: @@ -897,7 +911,7 @@ class MeshReporter: lines.append(f" {el}") # Recommendations for this node (trend-aware) - recs = self._node_recommendations(node, unified) + recs = self._node_recommendations(node) if recs: lines.append("") lines.append("Recommendations:") @@ -906,24 +920,23 @@ class MeshReporter: return "\n".join(lines) - def _node_recommendations(self, node: "NodeHealth", unified=None) -> list[str]: + def _node_recommendations(self, node: "UnifiedNode") -> list[str]: """Generate recommendations for a specific node. Args: - node: NodeHealth instance - unified: Optional UnifiedNode for historical data + node: UnifiedNode instance with all fields """ recs = [] # High packet count with trend context - if node.non_text_packets > self.health_engine.packet_threshold: - ratio = node.non_text_packets / self.health_engine.packet_threshold + if (node.packets_sent_24h - node.text_messages_24h) > self.health_engine.packet_threshold: + ratio = (node.packets_sent_24h - node.text_messages_24h) / self.health_engine.packet_threshold # Check if trending up trend_note = "" if unified: - avg_7d = unified.packets_sent_7d / 7 if unified.packets_sent_7d else 0 - if avg_7d > 0 and node.packet_count_24h > avg_7d * 1.5: + avg_7d = node.packets_sent_7d / 7 if node.packets_sent_7d else 0 + if avg_7d > 0 and node.packets_sent_24h > avg_7d * 1.5: trend_note = " (trending up vs 7d avg)" recs.append( @@ -931,7 +944,8 @@ class MeshReporter: ) # Position interval too frequent (< 300s = 5 min) - est_interval = node.estimated_position_interval + pos_count = node.packets_by_type.get("POSITION_APP", 0) + est_interval = 86400 / pos_count if pos_count > 0 else None if est_interval is not None and est_interval < 300: recs.append( f"Position interval ~{int(est_interval)}s is aggressive. " @@ -968,7 +982,7 @@ class MeshReporter: # Offline if not node.is_online: - age = _format_age(node.last_seen) + age = _format_age(node.last_heard) recs.append(f"Node offline since {age}. Check power and connectivity.") # Infrastructure node without MQTT uplink @@ -978,31 +992,30 @@ class MeshReporter: "Consider enabling for better mesh visibility." ) - # Environmental recommendations (from unified node) - if unified: + # Environmental recommendations # Freezing temperature warning for battery nodes - if unified.temperature is not None and unified.temperature < 0: - if unified.battery_percent is not None and unified.battery_percent <= 100: + if node.temperature is not None and node.temperature < 0: + if node.battery_percent is not None and node.battery_percent <= 100: recs.append( - f"Temperature {unified.temperature:.1f}C - below freezing reduces battery capacity 20-40%." + f"Temperature {node.temperature:.1f}C - below freezing reduces battery capacity 20-40%." ) # High humidity condensation risk - if unified.humidity is not None and unified.humidity > 90: + if node.humidity is not None and node.humidity > 90: recs.append( - f"Humidity at {unified.humidity:.0f}% - condensation risk. Ensure enclosure is sealed." + f"Humidity at {node.humidity:.0f}% - condensation risk. Ensure enclosure is sealed." ) # Poor air quality - if unified.pm2_5 is not None and unified.pm2_5 > 35: + if node.pm2_5 is not None and node.pm2_5 > 35: recs.append( - f"PM2.5 at {unified.pm2_5:.1f} ug/m3 - unhealthy air quality in this area." + f"PM2.5 at {node.pm2_5:.1f} ug/m3 - unhealthy air quality in this area." ) # High wind - if unified.wind_speed is not None and unified.wind_speed > 20: + if node.wind_speed is not None and node.wind_speed > 20: recs.append( - f"Wind speed {unified.wind_speed:.1f} m/s - check antenna mounting and cable strain relief." + f"Wind speed {node.wind_speed:.1f} m/s - check antenna mounting and cable strain relief." ) return recs @@ -1017,9 +1030,8 @@ class MeshReporter: if scope == "node" and scope_value: node = self._find_node(scope_value) - unified = self.data_store.get_node(node.node_num) if node else None if node: - recs.extend(self._node_recommendations(node, unified)) + recs.extend(self._node_recommendations(node)) elif scope == "region" and scope_value: region = self._find_region(scope_value) @@ -1060,9 +1072,13 @@ class MeshReporter: # Flagged nodes (high packet senders) flagged = [] - for nid in region.node_ids: + for nid_str in region.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue node = health.nodes.get(nid) - if node and node.non_text_packets > self.health_engine.packet_threshold: + if node and (node.packets_sent_24h - node.text_messages_24h) > self.health_engine.packet_threshold: flagged.append(node) if flagged: names = ", ".join( @@ -1075,7 +1091,11 @@ class MeshReporter: # Check for nodes with aggressive position intervals aggressive_interval_nodes = [] - for nid in region.node_ids: + for nid_str in region.node_ids: + try: + nid = int(nid_str) + except ValueError: + continue node = health.nodes.get(nid) if node: est = node.estimated_position_interval @@ -1273,12 +1293,13 @@ class MeshReporter: if not node: return f"Node '{node_identifier}' not found" - unified = self.data_store.get_node(node.node_num) + # All fields now directly on node (UnifiedNode) + unified = node # Build compact status display_name = node.short_name or node.long_name or f"!{node.node_num:08x}" status = "ON" if node.is_online else "OFF" - age = _format_age(node.last_seen) + age = _format_age(node.last_heard) parts = [f"{display_name} [{status}]"] @@ -1293,16 +1314,16 @@ class MeshReporter: parts.append(f"seen {age}") # Traffic - if node.packet_count_24h > 0: - parts.append(f"{node.packet_count_24h} pkts/24h") + if node.packets_sent_24h > 0: + parts.append(f"{node.packets_sent_24h} pkts/24h") # Channel util if node.channel_utilization is not None: parts.append(f"util {node.channel_utilization:.0f}%") # Neighbors - if unified and unified.neighbor_count > 0: - parts.append(f"{unified.neighbor_count} nbrs") + if node.neighbor_count > 0: + parts.append(f"{node.neighbor_count} nbrs") line1 = " | ".join(parts) @@ -1312,7 +1333,7 @@ class MeshReporter: warnings.append("! OFFLINE") elif node.battery_percent is not None and node.battery_percent <= 20 and node.battery_percent <= 100: warnings.append("! LOW BAT") - if node.non_text_packets > self.health_engine.packet_threshold: + if (node.packets_sent_24h - node.text_messages_24h) > self.health_engine.packet_threshold: warnings.append("! HIGH TRAFFIC") if warnings: @@ -1351,7 +1372,7 @@ class MeshReporter: return None - def _find_node(self, identifier: str) -> Optional["NodeHealth"]: + def _find_node(self, identifier: str) -> Optional["UnifiedNode"]: """Find a node by shortname, longname, nodeId, or nodeNum.""" health = self.health_engine.mesh_health if not health: