refactor: DELETE NodeHealth — reporter uses UnifiedNode directly

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 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-05 05:12:45 +00:00
commit 4183abe755
2 changed files with 209 additions and 422 deletions

View file

@ -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 []

View file

@ -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: