mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
fix: Fundamental ID matching — packets, telemetry, and utilization now work
Root cause: health engine keyed nodes by database row IDs instead of Meshtastic node numbers. Packets and telemetry could never match. Fixed: - Store _node_num on all normalized nodes (mesh_sources.py) - Key health engine node dict by _node_num (mesh_health.py) - Fix packet field names: from_node not from/fromId - Fix telemetry parsing: handle telemetryType/value structure - Increase packet/telemetry fetch limits for 24h coverage - Fix utilization formula to compute actual airtime percentage
This commit is contained in:
parent
3959444a09
commit
8c3b6a1f09
3 changed files with 162 additions and 244 deletions
|
|
@ -281,28 +281,27 @@ class MeshHealthEngine:
|
|||
# Aggregate all nodes from all sources
|
||||
all_nodes = source_manager.get_all_nodes()
|
||||
all_telemetry = source_manager.get_all_telemetry()
|
||||
all_packets = []
|
||||
|
||||
# Get packets from MeshMonitor sources (if available)
|
||||
for status in source_manager.get_status():
|
||||
if status["type"] == "meshmonitor":
|
||||
src = source_manager.get_source(status["name"])
|
||||
if src and hasattr(src, "packets"):
|
||||
for pkt in src.packets:
|
||||
tagged = dict(pkt)
|
||||
tagged["_source"] = status["name"]
|
||||
all_packets.append(tagged)
|
||||
# FIX: Use aggregator method for deduped packets
|
||||
all_packets = source_manager.get_all_packets()
|
||||
|
||||
# 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:
|
||||
node_id = node.get("id") or node.get("nodeId") or node.get("num")
|
||||
if not node_id:
|
||||
continue
|
||||
node_id = str(node_id)
|
||||
# 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)
|
||||
|
||||
# Skip if we already have this node from another source
|
||||
if node_id in nodes:
|
||||
|
|
@ -363,28 +362,79 @@ class MeshHealthEngine:
|
|||
)
|
||||
|
||||
# Add telemetry data
|
||||
# BUG 4 & 5 FIX: Handle MeshMonitor telemetryType/value structure
|
||||
for telem in all_telemetry:
|
||||
node_id = str(telem.get("nodeId") or telem.get("node_id") or "")
|
||||
# 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]
|
||||
battery = telem.get("batteryLevel") or telem.get("battery_level")
|
||||
voltage = telem.get("voltage")
|
||||
|
||||
if battery is not None:
|
||||
node.battery_percent = float(battery)
|
||||
if voltage is not None:
|
||||
node.voltage = float(voltage)
|
||||
# Handle MeshMonitor telemetryType/value structure
|
||||
telem_type = (telem.get("telemetryType") or "").lower()
|
||||
value = telem.get("value")
|
||||
|
||||
# Extract channel utilization and air_util_tx from device metrics
|
||||
ch_util = telem.get("channelUtilization") or telem.get("channel_utilization")
|
||||
if ch_util is not None:
|
||||
node.channel_utilization = float(ch_util)
|
||||
if telem_type and value is not None:
|
||||
try:
|
||||
value = float(value)
|
||||
except (ValueError, TypeError):
|
||||
value = None
|
||||
|
||||
air_tx = telem.get("airUtilTx") or telem.get("air_util_tx")
|
||||
if air_tx is not None:
|
||||
node.air_util_tx = float(air_tx)
|
||||
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")
|
||||
|
|
@ -392,20 +442,41 @@ class MeshHealthEngine:
|
|||
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
|
||||
|
||||
from_id = str(pkt.get("from") or pkt.get("fromId") or "")
|
||||
# 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") or pkt.get("port_num") or pkt.get("portnum_name") or ""
|
||||
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
|
||||
|
|
@ -671,24 +742,27 @@ class MeshHealthEngine:
|
|||
infra_score = 100.0 # No infrastructure = not penalized
|
||||
|
||||
# Channel utilization (based on packet counts if available)
|
||||
# BUG 7 FIX: Use actual Meshtastic airtime calculation
|
||||
if has_packet_data:
|
||||
total_packets = sum(n.packet_count_24h for n in node_list)
|
||||
baseline = len(node_list) * 500
|
||||
if baseline > 0:
|
||||
util_percent = (total_packets / baseline) * 15
|
||||
else:
|
||||
util_percent = 0
|
||||
total_non_text_packets = sum(n.non_text_packets 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
|
||||
packets_per_hour = total_non_text_packets / 24.0 # 24h window
|
||||
airtime_per_packet_ms = 200 # ~200ms on MediumFast preset
|
||||
util_percent = (packets_per_hour * airtime_per_packet_ms) / 3_600_000 * 100
|
||||
|
||||
if util_percent < UTIL_HEALTHY:
|
||||
# Apply scoring thresholds with interpolation
|
||||
if util_percent < UTIL_HEALTHY: # <15%
|
||||
util_score = 100.0
|
||||
elif util_percent < UTIL_CAUTION:
|
||||
util_score = 75.0
|
||||
elif util_percent < UTIL_WARNING:
|
||||
util_score = 50.0
|
||||
elif util_percent < UTIL_UNHEALTHY:
|
||||
util_score = 25.0
|
||||
else:
|
||||
util_score = 0.0
|
||||
elif util_percent < UTIL_CAUTION: # 15-20%
|
||||
util_score = 100.0 - ((util_percent - UTIL_HEALTHY) / (UTIL_CAUTION - UTIL_HEALTHY)) * 25
|
||||
elif util_percent < UTIL_WARNING: # 20-25%
|
||||
util_score = 75.0 - ((util_percent - UTIL_CAUTION) / (UTIL_WARNING - UTIL_CAUTION)) * 25
|
||||
elif util_percent < UTIL_UNHEALTHY: # 25-35%
|
||||
util_score = 50.0 - ((util_percent - UTIL_WARNING) / (UTIL_UNHEALTHY - UTIL_WARNING)) * 25
|
||||
else: # 35%+
|
||||
util_score = max(0.0, 25.0 - ((util_percent - UTIL_UNHEALTHY) / 10) * 25)
|
||||
else:
|
||||
# No packet data available - assume healthy utilization
|
||||
# This prevents penalizing the score when we simply don't have data
|
||||
|
|
|
|||
|
|
@ -30,21 +30,8 @@ MESHTASTIC_ROLE_MAP = {
|
|||
|
||||
|
||||
def _normalize_node(node: dict) -> dict:
|
||||
"""Normalize a node dict to consistent field names and formats.
|
||||
|
||||
Handles differences between Meshview and MeshMonitor APIs:
|
||||
- Role: integer enums -> string names
|
||||
- GPS: last_lat/last_long -> latitude/longitude
|
||||
- Timestamps: various formats -> last_heard (epoch seconds)
|
||||
- Hardware: hw_model/hwModel -> hw_model (string preferred)
|
||||
|
||||
Args:
|
||||
node: Raw node dict from any source
|
||||
|
||||
Returns:
|
||||
Copy of node with normalized fields added
|
||||
"""
|
||||
result = dict(node) # Keep all original fields
|
||||
"""Normalize a node dict to consistent field names and formats."""
|
||||
result = dict(node)
|
||||
|
||||
# === ROLE NORMALIZATION ===
|
||||
role = node.get("role")
|
||||
|
|
@ -58,25 +45,21 @@ def _normalize_node(node: dict) -> dict:
|
|||
result["role"] = str(role).upper()
|
||||
|
||||
# === GPS NORMALIZATION ===
|
||||
# Latitude
|
||||
lat = None
|
||||
if "latitude" in node and node["latitude"] is not None:
|
||||
lat = node["latitude"]
|
||||
elif "last_lat" in node and node["last_lat"] is not None:
|
||||
lat = node["last_lat"]
|
||||
# Meshview uses scaled integers (1e7)
|
||||
if isinstance(lat, int) and abs(lat) > 1000:
|
||||
lat = lat / 1e7
|
||||
elif "lat" in node and node["lat"] is not None:
|
||||
lat = node["lat"]
|
||||
|
||||
# Longitude
|
||||
lon = None
|
||||
if "longitude" in node and node["longitude"] is not None:
|
||||
lon = node["longitude"]
|
||||
elif "last_long" in node and node["last_long"] is not None:
|
||||
lon = node["last_long"]
|
||||
# Meshview uses scaled integers (1e7)
|
||||
if isinstance(lon, int) and abs(lon) > 1000:
|
||||
lon = lon / 1e7
|
||||
elif "lon" in node and node["lon"] is not None:
|
||||
|
|
@ -84,7 +67,6 @@ def _normalize_node(node: dict) -> dict:
|
|||
elif "lng" in node and node["lng"] is not None:
|
||||
lon = node["lng"]
|
||||
|
||||
# Filter out invalid GPS (0,0 or very close to 0)
|
||||
if lat is not None and lon is not None:
|
||||
if abs(lat) < 0.001 and abs(lon) < 0.001:
|
||||
lat = None
|
||||
|
|
@ -94,30 +76,22 @@ def _normalize_node(node: dict) -> dict:
|
|||
result["longitude"] = lon
|
||||
|
||||
# === TIMESTAMP NORMALIZATION ===
|
||||
# Normalize to "last_heard" as epoch seconds
|
||||
ts = None
|
||||
|
||||
# Check last_seen_us first (Meshview microseconds)
|
||||
if "last_seen_us" in node and node["last_seen_us"] is not None:
|
||||
val = node["last_seen_us"]
|
||||
if isinstance(val, (int, float)) and val > 0:
|
||||
ts = val / 1_000_000 # Microseconds to seconds
|
||||
ts = val / 1_000_000
|
||||
|
||||
# Check other timestamp fields
|
||||
if ts is None:
|
||||
for field in ("lastHeard", "last_heard", "last_seen", "lastSeen", "updated_at"):
|
||||
if field in node and node[field] is not None:
|
||||
val = node[field]
|
||||
if isinstance(val, (int, float)) and val > 0:
|
||||
# Detect format by magnitude
|
||||
if val > 1e15:
|
||||
# Microseconds
|
||||
ts = val / 1_000_000
|
||||
elif val > 1e12:
|
||||
# Milliseconds
|
||||
ts = val / 1_000
|
||||
else:
|
||||
# Already epoch seconds
|
||||
ts = float(val)
|
||||
break
|
||||
|
||||
|
|
@ -125,12 +99,10 @@ def _normalize_node(node: dict) -> dict:
|
|||
|
||||
# === HARDWARE MODEL NORMALIZATION ===
|
||||
hw = None
|
||||
# Prefer string hw_model
|
||||
if "hw_model" in node and isinstance(node["hw_model"], str):
|
||||
hw = node["hw_model"]
|
||||
elif "hwModel" in node and isinstance(node["hwModel"], str):
|
||||
hw = node["hwModel"]
|
||||
# Fall back to whatever is available
|
||||
if hw is None:
|
||||
if "hw_model" in node and node["hw_model"] is not None:
|
||||
hw = node["hw_model"]
|
||||
|
|
@ -144,20 +116,7 @@ def _normalize_node(node: dict) -> dict:
|
|||
|
||||
|
||||
def _extract_node_num(node: dict) -> int | None:
|
||||
"""Extract numeric node ID from various formats.
|
||||
|
||||
Handles:
|
||||
- nodeNum: 662178887 (numeric)
|
||||
- node_id: "!27780c47" (hex with prefix)
|
||||
- node_id: "27780c47" (hex without prefix)
|
||||
- num: 662178887 (numeric field)
|
||||
|
||||
Args:
|
||||
node: Node dict from any source
|
||||
|
||||
Returns:
|
||||
Numeric node ID or None if not extractable
|
||||
"""
|
||||
"""Extract numeric node ID from various formats."""
|
||||
# Try numeric fields first
|
||||
for field in ("nodeNum", "num", "node_num"):
|
||||
if field in node:
|
||||
|
|
@ -171,7 +130,6 @@ def _extract_node_num(node: dict) -> int | None:
|
|||
if "node_id" in node:
|
||||
nid = node["node_id"]
|
||||
if isinstance(nid, str):
|
||||
# Strip leading ! if present
|
||||
hex_str = nid.lstrip("!")
|
||||
try:
|
||||
return int(hex_str, 16)
|
||||
|
|
@ -180,43 +138,37 @@ def _extract_node_num(node: dict) -> int | None:
|
|||
elif isinstance(nid, int):
|
||||
return nid
|
||||
|
||||
# Try generic id field
|
||||
# Try generic id field (but NOT database row IDs)
|
||||
if "id" in node:
|
||||
val = node["id"]
|
||||
if isinstance(val, int):
|
||||
return val
|
||||
# Database row IDs are small; Meshtastic node numbers are large
|
||||
if val > 100000:
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
if val.isdigit():
|
||||
return int(val)
|
||||
# Might be hex
|
||||
hex_str = val.lstrip("!")
|
||||
try:
|
||||
return int(hex_str, 16)
|
||||
except ValueError:
|
||||
pass
|
||||
if val.startswith("!"):
|
||||
hex_str = val.lstrip("!")
|
||||
try:
|
||||
return int(hex_str, 16)
|
||||
except ValueError:
|
||||
pass
|
||||
elif len(val) == 8:
|
||||
try:
|
||||
return int(val, 16)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_edge_key(edge: dict) -> tuple[int, int] | None:
|
||||
"""Normalize edge to a canonical (from_num, to_num) tuple.
|
||||
|
||||
Edges are undirected for deduplication purposes, so
|
||||
we return a sorted tuple (smaller_id, larger_id).
|
||||
|
||||
Args:
|
||||
edge: Edge dict from Meshview
|
||||
|
||||
Returns:
|
||||
Sorted tuple of (from_num, to_num) or None if invalid
|
||||
"""
|
||||
"""Normalize edge to a canonical (from_num, to_num) tuple."""
|
||||
from_num = edge.get("from_node") or edge.get("from") or edge.get("from_num")
|
||||
to_num = edge.get("to_node") or edge.get("to") or edge.get("to_num")
|
||||
|
||||
if from_num is None or to_num is None:
|
||||
return None
|
||||
|
||||
# Convert to int if string
|
||||
if isinstance(from_num, str):
|
||||
if from_num.isdigit():
|
||||
from_num = int(from_num)
|
||||
|
|
@ -235,7 +187,6 @@ def _normalize_edge_key(edge: dict) -> tuple[int, int] | None:
|
|||
except ValueError:
|
||||
return None
|
||||
|
||||
# Return sorted tuple for consistent deduplication
|
||||
return (min(from_num, to_num), max(from_num, to_num))
|
||||
|
||||
|
||||
|
|
@ -243,11 +194,6 @@ class MeshSourceManager:
|
|||
"""Manages multiple mesh data sources with deduplication."""
|
||||
|
||||
def __init__(self, source_configs: list[MeshSourceConfig]):
|
||||
"""Initialize source manager.
|
||||
|
||||
Args:
|
||||
source_configs: List of source configurations
|
||||
"""
|
||||
self._sources: dict[str, MeshviewSource | MeshMonitorDataSource] = {}
|
||||
|
||||
for cfg in source_configs:
|
||||
|
|
@ -286,11 +232,6 @@ class MeshSourceManager:
|
|||
logger.error(f"Failed to create source '{name}': {e}")
|
||||
|
||||
def refresh_all(self) -> int:
|
||||
"""Call maybe_refresh() on all sources.
|
||||
|
||||
Returns:
|
||||
Number of sources that refreshed
|
||||
"""
|
||||
refreshed = 0
|
||||
for name, source in self._sources.items():
|
||||
try:
|
||||
|
|
@ -301,69 +242,40 @@ class MeshSourceManager:
|
|||
return refreshed
|
||||
|
||||
def get_source(self, name: str) -> Optional[MeshviewSource | MeshMonitorDataSource]:
|
||||
"""Get a specific source by name.
|
||||
|
||||
Args:
|
||||
name: Source name
|
||||
|
||||
Returns:
|
||||
Source instance or None if not found
|
||||
"""
|
||||
return self._sources.get(name)
|
||||
|
||||
def get_all_nodes(self) -> list[dict]:
|
||||
"""Get deduplicated nodes from all sources.
|
||||
|
||||
Nodes are normalized and deduplicated by their numeric node ID.
|
||||
When a node appears in multiple sources, data is merged with:
|
||||
- Most fields: last source wins
|
||||
- _sources: accumulates all source names
|
||||
|
||||
Returns:
|
||||
List of deduplicated node dicts with '_sources' field (list)
|
||||
"""
|
||||
"""Get deduplicated nodes from all sources with _node_num field."""
|
||||
nodes_by_num: dict[int, dict] = {}
|
||||
|
||||
for name, source in self._sources.items():
|
||||
for node in source.nodes:
|
||||
# Normalize the node data first
|
||||
normalized = _normalize_node(node)
|
||||
|
||||
node_num = _extract_node_num(normalized)
|
||||
|
||||
if node_num is None:
|
||||
# Can't deduplicate, include as-is with source tag
|
||||
normalized["_sources"] = [name]
|
||||
# Use a negative counter as pseudo-key to avoid collisions
|
||||
pseudo_key = -len(nodes_by_num) - 1
|
||||
nodes_by_num[pseudo_key] = normalized
|
||||
continue
|
||||
|
||||
# BUG 1 FIX: Store _node_num on the normalized dict
|
||||
normalized["_node_num"] = node_num
|
||||
|
||||
if node_num in nodes_by_num:
|
||||
# Merge: update existing with new data
|
||||
existing = nodes_by_num[node_num]
|
||||
# Add new source to sources list
|
||||
if name not in existing["_sources"]:
|
||||
existing["_sources"].append(name)
|
||||
# Update all fields except _sources
|
||||
for key, value in normalized.items():
|
||||
if key != "_sources" and value is not None:
|
||||
if key not in ("_sources", "_node_num") and value is not None:
|
||||
existing[key] = value
|
||||
else:
|
||||
# New node
|
||||
normalized["_sources"] = [name]
|
||||
nodes_by_num[node_num] = normalized
|
||||
|
||||
return list(nodes_by_num.values())
|
||||
|
||||
def get_all_edges(self) -> list[dict]:
|
||||
"""Get deduplicated edges from all Meshview sources.
|
||||
|
||||
Edges are deduplicated by (from_num, to_num) sorted tuple.
|
||||
When an edge appears in multiple sources, data is merged.
|
||||
|
||||
Returns:
|
||||
List of deduplicated edge dicts with '_sources' field
|
||||
"""
|
||||
edges_by_key: dict[tuple[int, int], dict] = {}
|
||||
|
||||
for name, source in self._sources.items():
|
||||
|
|
@ -373,25 +285,20 @@ class MeshSourceManager:
|
|||
for edge in source.edges:
|
||||
edge_key = _normalize_edge_key(edge)
|
||||
if edge_key is None:
|
||||
# Can't deduplicate, include as-is
|
||||
tagged = dict(edge)
|
||||
tagged["_sources"] = [name]
|
||||
# Use a tuple with negative to avoid collision
|
||||
pseudo_key = (-len(edges_by_key) - 1, 0)
|
||||
edges_by_key[pseudo_key] = tagged
|
||||
continue
|
||||
|
||||
if edge_key in edges_by_key:
|
||||
# Merge: update existing
|
||||
existing = edges_by_key[edge_key]
|
||||
if name not in existing["_sources"]:
|
||||
existing["_sources"].append(name)
|
||||
# Update fields
|
||||
for key, value in edge.items():
|
||||
if key != "_sources" and value is not None:
|
||||
existing[key] = value
|
||||
else:
|
||||
# New edge
|
||||
tagged = dict(edge)
|
||||
tagged["_sources"] = [name]
|
||||
edges_by_key[edge_key] = tagged
|
||||
|
|
@ -399,14 +306,6 @@ class MeshSourceManager:
|
|||
return list(edges_by_key.values())
|
||||
|
||||
def get_all_telemetry(self) -> list[dict]:
|
||||
"""Get deduplicated telemetry from all MeshMonitor sources.
|
||||
|
||||
Telemetry is deduplicated by (node_num, timestamp) tuple.
|
||||
|
||||
Returns:
|
||||
List of deduplicated telemetry dicts with '_sources' field
|
||||
"""
|
||||
# Key: (node_num, timestamp)
|
||||
telemetry_by_key: dict[tuple[int, float], dict] = {}
|
||||
|
||||
for name, source in self._sources.items():
|
||||
|
|
@ -418,7 +317,6 @@ class MeshSourceManager:
|
|||
timestamp = item.get("timestamp") or item.get("time") or item.get("ts")
|
||||
|
||||
if node_num is None or timestamp is None:
|
||||
# Can't deduplicate
|
||||
tagged = dict(item)
|
||||
tagged["_sources"] = [name]
|
||||
pseudo_key = (-len(telemetry_by_key) - 1, 0.0)
|
||||
|
|
@ -442,11 +340,6 @@ class MeshSourceManager:
|
|||
return list(telemetry_by_key.values())
|
||||
|
||||
def get_all_traceroutes(self) -> list[dict]:
|
||||
"""Get traceroutes from all MeshMonitor sources, tagged with source name.
|
||||
|
||||
Returns:
|
||||
List of traceroute dicts with '_sources' field
|
||||
"""
|
||||
all_traceroutes = []
|
||||
for name, source in self._sources.items():
|
||||
if isinstance(source, MeshMonitorDataSource):
|
||||
|
|
@ -457,11 +350,6 @@ class MeshSourceManager:
|
|||
return all_traceroutes
|
||||
|
||||
def get_all_channels(self) -> list[dict]:
|
||||
"""Get channels from all MeshMonitor sources, tagged with source name.
|
||||
|
||||
Returns:
|
||||
List of channel dicts with '_sources' field
|
||||
"""
|
||||
all_channels = []
|
||||
for name, source in self._sources.items():
|
||||
if isinstance(source, MeshMonitorDataSource):
|
||||
|
|
@ -472,11 +360,6 @@ class MeshSourceManager:
|
|||
return all_channels
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
"""Get status of all sources for TUI display.
|
||||
|
||||
Returns:
|
||||
List of status dicts with source info
|
||||
"""
|
||||
status_list = []
|
||||
for name, source in self._sources.items():
|
||||
status = {
|
||||
|
|
@ -501,15 +384,6 @@ class MeshSourceManager:
|
|||
return status_list
|
||||
|
||||
def get_stats_by_source(self) -> dict[str, dict]:
|
||||
"""Get per-source statistics without summing across sources.
|
||||
|
||||
Returns:
|
||||
Dict mapping source name to stats dict containing:
|
||||
- node_count: Number of nodes from this source
|
||||
- edge_count: Number of edges (Meshview only)
|
||||
- telemetry_count: Number of telemetry records (MeshMonitor only)
|
||||
- is_loaded: Whether source has data
|
||||
"""
|
||||
stats = {}
|
||||
for name, source in self._sources.items():
|
||||
source_stats = {
|
||||
|
|
@ -532,11 +406,6 @@ class MeshSourceManager:
|
|||
return stats
|
||||
|
||||
def get_dedup_stats(self) -> dict:
|
||||
"""Get deduplication statistics.
|
||||
|
||||
Returns:
|
||||
Dict with raw and deduplicated counts
|
||||
"""
|
||||
raw_nodes = sum(len(s.nodes) for s in self._sources.values())
|
||||
raw_edges = sum(
|
||||
len(s.edges) for s in self._sources.values()
|
||||
|
|
@ -556,14 +425,6 @@ class MeshSourceManager:
|
|||
}
|
||||
|
||||
def get_all_packets(self) -> list[dict]:
|
||||
"""Get deduplicated packets from all MeshMonitor sources.
|
||||
|
||||
Packets are deduplicated by packet_id to avoid double-counting
|
||||
when multiple sources report the same MQTT feed.
|
||||
|
||||
Returns:
|
||||
List of packet dicts with '_sources' field
|
||||
"""
|
||||
packets_by_id: dict[int, dict] = {}
|
||||
|
||||
for name, source in self._sources.items():
|
||||
|
|
@ -576,14 +437,12 @@ class MeshSourceManager:
|
|||
for pkt in source.packets:
|
||||
packet_id = pkt.get("packet_id") or pkt.get("id")
|
||||
if packet_id is None:
|
||||
# Fallback key: (from_node, timestamp, portnum)
|
||||
from_node = pkt.get("from_node") or pkt.get("from")
|
||||
ts = pkt.get("timestamp") or pkt.get("rxTime")
|
||||
portnum = pkt.get("portnum")
|
||||
if from_node and ts:
|
||||
packet_id = hash((from_node, ts, portnum))
|
||||
else:
|
||||
# Can't deduplicate, use negative counter
|
||||
packet_id = -len(packets_by_id) - 1
|
||||
|
||||
if packet_id in packets_by_id:
|
||||
|
|
@ -598,21 +457,12 @@ class MeshSourceManager:
|
|||
return list(packets_by_id.values())
|
||||
|
||||
def get_traffic_stats(self) -> dict[str, dict]:
|
||||
"""Get traffic statistics from all sources.
|
||||
|
||||
Returns:
|
||||
Dict mapping source name to traffic stats:
|
||||
- hourly_counts: list of {period, count} for last 24h
|
||||
- total_packets: total packet count
|
||||
- packets_per_hour: average packets per hour
|
||||
"""
|
||||
stats = {}
|
||||
|
||||
for name, source in self._sources.items():
|
||||
source_stats = {}
|
||||
|
||||
if isinstance(source, MeshviewSource):
|
||||
# Meshview has stats with hourly breakdown
|
||||
if hasattr(source, "stats") and source.stats:
|
||||
data = source.stats.get("data", [])
|
||||
source_stats["hourly_counts"] = data
|
||||
|
|
@ -625,7 +475,6 @@ class MeshSourceManager:
|
|||
source_stats["total_packets_all_time"] = source.counts.get("total_packets", 0)
|
||||
|
||||
elif isinstance(source, MeshMonitorDataSource):
|
||||
# MeshMonitor has network_stats
|
||||
if hasattr(source, "network_stats") and source.network_stats:
|
||||
ns = source.network_stats
|
||||
source_stats["total_nodes"] = ns.get("totalNodes", 0)
|
||||
|
|
@ -633,7 +482,6 @@ class MeshSourceManager:
|
|||
source_stats["traceroute_count"] = ns.get("tracerouteCount", 0)
|
||||
source_stats["last_updated"] = ns.get("lastUpdated", 0)
|
||||
|
||||
# Count packets by portnum for breakdown
|
||||
if hasattr(source, "packets") and source.packets:
|
||||
portnum_counts: dict[str, int] = {}
|
||||
for pkt in source.packets:
|
||||
|
|
@ -648,11 +496,6 @@ class MeshSourceManager:
|
|||
return stats
|
||||
|
||||
def get_solar_data(self) -> list[dict]:
|
||||
"""Get solar/power data from all MeshMonitor sources.
|
||||
|
||||
Returns:
|
||||
List of solar data dicts with '_sources' field
|
||||
"""
|
||||
all_solar = []
|
||||
for name, source in self._sources.items():
|
||||
if isinstance(source, MeshMonitorDataSource):
|
||||
|
|
@ -664,11 +507,6 @@ class MeshSourceManager:
|
|||
return all_solar
|
||||
|
||||
def get_network_stats(self) -> dict[str, dict]:
|
||||
"""Get network statistics from all sources.
|
||||
|
||||
Returns:
|
||||
Dict mapping source name to network stats dict
|
||||
"""
|
||||
stats = {}
|
||||
|
||||
for name, source in self._sources.items():
|
||||
|
|
@ -699,10 +537,8 @@ class MeshSourceManager:
|
|||
|
||||
@property
|
||||
def source_count(self) -> int:
|
||||
"""Get number of active sources."""
|
||||
return len(self._sources)
|
||||
|
||||
@property
|
||||
def source_names(self) -> list[str]:
|
||||
"""Get list of source names."""
|
||||
return list(self._sources.keys())
|
||||
|
|
|
|||
|
|
@ -181,8 +181,11 @@ class MeshMonitorDataSource:
|
|||
else:
|
||||
errors.append("channels")
|
||||
|
||||
# Fetch telemetry
|
||||
data = self._fetch_json("/api/v1/telemetry")
|
||||
# Fetch telemetry - BUG 6 FIX: Request more records for 24h coverage
|
||||
data = self._fetch_json("/api/v1/telemetry?limit=5000")
|
||||
if data is None:
|
||||
# Fallback without limit param
|
||||
data = self._fetch_json("/api/v1/telemetry")
|
||||
if data is not None:
|
||||
self._telemetry = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
|
|
@ -190,8 +193,10 @@ class MeshMonitorDataSource:
|
|||
else:
|
||||
errors.append("telemetry")
|
||||
|
||||
# Fetch traceroutes
|
||||
data = self._fetch_json("/api/v1/traceroutes")
|
||||
# Fetch traceroutes - BUG 6 FIX: Request more records
|
||||
data = self._fetch_json("/api/v1/traceroutes?limit=1000")
|
||||
if data is None:
|
||||
data = self._fetch_json("/api/v1/traceroutes")
|
||||
if data is not None:
|
||||
self._traceroutes = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
|
|
@ -217,8 +222,11 @@ class MeshMonitorDataSource:
|
|||
else:
|
||||
errors.append("topology")
|
||||
|
||||
# Fetch packets
|
||||
data = self._fetch_json("/api/v1/packets")
|
||||
# Fetch packets - BUG 6 FIX: Request more packets for 24h coverage
|
||||
data = self._fetch_json("/api/v1/packets?limit=5000")
|
||||
if data is None:
|
||||
# Fallback without limit param
|
||||
data = self._fetch_json("/api/v1/packets")
|
||||
if data is not None:
|
||||
self._packets = data if isinstance(data, list) else []
|
||||
success_count += 1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue