mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
a384fd7a20
commit
4183abe755
2 changed files with 209 additions and 422 deletions
|
|
@ -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 []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue