mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
feat: Rich Tier 1 data - named infra per region, problem nodes, expanded recommendations
Tier 1 now includes: - Every infrastructure node BY NAME per region with status/battery/util/gateways - Problem nodes section: offline infra, critical battery, high util, coverage risks - Per-region coverage with gateway counts and single-gw counts - Environmental data per region - All 5 pillars with weights - Expanded recommendations with specifics (10 max, up from 5) - LLM prompt simplified: data speaks for itself Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
8d1a48ea08
commit
56536f55c8
2 changed files with 276 additions and 475 deletions
|
|
@ -176,125 +176,193 @@ class MeshReporter:
|
||||||
return local or desc
|
return local or desc
|
||||||
|
|
||||||
def build_tier1_summary(self) -> str:
|
def build_tier1_summary(self) -> str:
|
||||||
"""Build compact mesh summary for LLM injection (~500-800 tokens).
|
"""Build comprehensive mesh health summary with full data for LLM context."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted summary string
|
|
||||||
"""
|
|
||||||
health = self.health_engine.mesh_health
|
health = self.health_engine.mesh_health
|
||||||
if not health:
|
if not health:
|
||||||
return "LIVE MESH HEALTH DATA: No data available yet."
|
return "MESH HEALTH: No data available."
|
||||||
|
|
||||||
|
import time
|
||||||
|
now = time.time()
|
||||||
|
age_seconds = now - health.last_computed if health.last_computed else 0
|
||||||
|
age_str = f"{int(age_seconds)}s ago" if age_seconds < 120 else f"{int(age_seconds/60)}m ago"
|
||||||
|
|
||||||
score = health.score
|
score = health.score
|
||||||
data_age = self.data_store.data_age_seconds
|
|
||||||
if data_age < 60:
|
|
||||||
age_str = f"{int(data_age)}s ago"
|
|
||||||
elif data_age < 3600:
|
|
||||||
age_str = f"{int(data_age / 60)}m ago"
|
|
||||||
else:
|
|
||||||
age_str = f"{int(data_age / 3600)}h ago"
|
|
||||||
|
|
||||||
# Infrastructure stats
|
|
||||||
infra_online = score.infra_online
|
|
||||||
infra_total = score.infra_total
|
|
||||||
infra_pct = int((infra_online / infra_total * 100) if infra_total > 0 else 100)
|
|
||||||
|
|
||||||
# Utilization - prefer device-reported
|
|
||||||
util = score.util_percent
|
|
||||||
util_data_available = score.util_data_available
|
|
||||||
if not util_data_available:
|
|
||||||
util_label = "N/A"
|
|
||||||
elif util < 15:
|
|
||||||
util_label = "Low"
|
|
||||||
elif util < 20:
|
|
||||||
util_label = "Moderate"
|
|
||||||
elif util < 25:
|
|
||||||
util_label = "Elevated"
|
|
||||||
else:
|
|
||||||
util_label = "High"
|
|
||||||
|
|
||||||
# Power breakdown
|
|
||||||
power_breakdown = self._get_power_breakdown()
|
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"LIVE MESH HEALTH DATA (as of {age_str}):",
|
f"LIVE MESH HEALTH DATA (as of {age_str}):",
|
||||||
"",
|
"",
|
||||||
f"Overall: {score.composite:.0f}/100 ({score.tier})",
|
f"OVERALL: {score.composite:.0f}/100 ({score.tier})",
|
||||||
f"Infrastructure: {infra_online}/{infra_total} online ({infra_pct}%)",
|
f" Infrastructure: {score.infrastructure:.0f}/100 ({score.infra_online}/{score.infra_total} online) - weight 30%",
|
||||||
|
f" Utilization: {score.utilization:.0f}/100 ({score.util_percent:.1f}% avg) - weight 25%",
|
||||||
|
f" Coverage: {score.coverage:.0f}/100 - weight 20%",
|
||||||
|
f" Behavior: {score.behavior:.0f}/100 ({score.flagged_nodes} flagged) - weight 15%",
|
||||||
|
f" Power: {score.power:.0f}/100 - weight 10%",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Channel Utilization with data availability
|
lines.append("")
|
||||||
if util_data_available:
|
lines.append("REGIONS:")
|
||||||
lines.append(f"Channel Utilization: {util:.1f}% avg ({util_label})")
|
|
||||||
else:
|
|
||||||
lines.append("Channel Utilization: No data available")
|
|
||||||
|
|
||||||
lines.append(f"Node Behavior: {score.flagged_nodes} nodes flagged")
|
for region in health.regions:
|
||||||
|
rs = region.score
|
||||||
|
context = self._region_context(region.name)
|
||||||
|
context_str = f" - {context}" if context else ""
|
||||||
|
|
||||||
# Power breakdown with USB/ok/low/critical counts
|
lines.append("")
|
||||||
if power_breakdown["total"] > 0:
|
lines.append(f" {region.name}{context_str}: {rs.composite:.0f}/100")
|
||||||
parts = []
|
|
||||||
if power_breakdown["usb"] > 0:
|
|
||||||
parts.append(f"{power_breakdown['usb']} USB")
|
|
||||||
if power_breakdown["ok"] > 0:
|
|
||||||
parts.append(f"{power_breakdown['ok']} ok")
|
|
||||||
if power_breakdown["low"] > 0:
|
|
||||||
parts.append(f"{power_breakdown['low']} low")
|
|
||||||
if power_breakdown["critical"] > 0:
|
|
||||||
parts.append(f"{power_breakdown['critical']} critical")
|
|
||||||
power_str = ", ".join(parts) if parts else "No battery data"
|
|
||||||
lines.append(f"Power: {power_str} ({score.solar_index:.0f}% solar)")
|
|
||||||
else:
|
|
||||||
lines.append(f"Power: No battery data ({score.solar_index:.0f}% solar)")
|
|
||||||
|
|
||||||
# Traffic trend
|
infra_nodes = []
|
||||||
traffic_trend = self._get_traffic_trend_summary()
|
client_nodes = []
|
||||||
if traffic_trend:
|
for nid_str in region.node_ids:
|
||||||
lines.append(f"Traffic Trend: {traffic_trend}")
|
try:
|
||||||
|
nid = int(nid_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if not node:
|
||||||
|
continue
|
||||||
|
if node.is_infrastructure:
|
||||||
|
infra_nodes.append(node)
|
||||||
|
else:
|
||||||
|
client_nodes.append(node)
|
||||||
|
|
||||||
|
if infra_nodes:
|
||||||
|
online = sum(1 for n in infra_nodes if n.is_online)
|
||||||
|
lines.append(f" Infrastructure ({online}/{len(infra_nodes)}):")
|
||||||
|
for node in sorted(infra_nodes, key=lambda n: (not n.is_online, n.short_name or '')):
|
||||||
|
status = "OK" if node.is_online else "OFFLINE"
|
||||||
|
name = _node_display_name(node.long_name, node.short_name, str(node.node_num))
|
||||||
|
age = _format_age(node.last_heard) if node.last_heard else "?"
|
||||||
|
|
||||||
|
parts = [f"seen {age}"]
|
||||||
|
if node.battery_percent is not None:
|
||||||
|
parts.append(_format_battery(node.battery_percent, node.voltage))
|
||||||
|
if node.channel_utilization is not None:
|
||||||
|
parts.append(f"util {node.channel_utilization:.1f}%")
|
||||||
|
if node.avg_gateways is not None:
|
||||||
|
total_gw = len(self.data_store._sources)
|
||||||
|
parts.append(f"{node.avg_gateways:.0f}/{total_gw} gw")
|
||||||
|
if node.neighbor_count > 0:
|
||||||
|
parts.append(f"{node.neighbor_count} neighbors")
|
||||||
|
if node.packets_sent_24h > 0:
|
||||||
|
parts.append(f"{node.packets_sent_24h} pkts/24h")
|
||||||
|
if node.uplink_enabled:
|
||||||
|
parts.append("MQTT")
|
||||||
|
|
||||||
|
lines.append(f" [{status}] {name}: {', '.join(parts)}")
|
||||||
|
else:
|
||||||
|
lines.append(f" Infrastructure: none in region")
|
||||||
|
|
||||||
|
region_gw = []
|
||||||
|
for nid_str in region.node_ids:
|
||||||
|
try:
|
||||||
|
nid = int(nid_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.avg_gateways is not None:
|
||||||
|
region_gw.append(node)
|
||||||
|
|
||||||
|
if region_gw:
|
||||||
|
avg_gw = sum(n.avg_gateways for n in region_gw) / len(region_gw)
|
||||||
|
single = sum(1 for n in region_gw if n.avg_gateways <= 1.0)
|
||||||
|
total_gw = len(self.data_store._sources)
|
||||||
|
lines.append(f" Coverage: {len(region_gw)} nodes, avg {avg_gw:.1f}/{total_gw} gw, {single} single-gateway")
|
||||||
|
|
||||||
|
env_in_region = []
|
||||||
|
for nid_str in region.node_ids:
|
||||||
|
try:
|
||||||
|
nid = int(nid_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.has_environment_sensor:
|
||||||
|
env_in_region.append(node)
|
||||||
|
|
||||||
|
if env_in_region:
|
||||||
|
temps = [n.temperature for n in env_in_region if _is_valid_temperature(n.temperature)]
|
||||||
|
if temps:
|
||||||
|
avg_t = sum(temps) / len(temps)
|
||||||
|
lines.append(f" Environment: {len(env_in_region)} sensors, temp {min(temps):.1f}-{max(temps):.1f}C (avg {avg_t:.1f}C)")
|
||||||
|
|
||||||
|
lines.append(f" Clients: {len(client_nodes)} nodes")
|
||||||
|
|
||||||
|
unlocated_count = len(health.unlocated_nodes)
|
||||||
|
if unlocated_count > 0:
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append(f" Unlocated: {unlocated_count} nodes (no GPS or neighbor data)")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("PROBLEM NODES:")
|
||||||
|
|
||||||
|
problems_found = False
|
||||||
|
|
||||||
|
offline_infra = [n for n in health.nodes.values() if n.is_infrastructure and not n.is_online]
|
||||||
|
if offline_infra:
|
||||||
|
problems_found = True
|
||||||
|
for node in offline_infra:
|
||||||
|
name = _node_display_name(node.long_name, node.short_name, str(node.node_num))
|
||||||
|
age = _format_age(node.last_heard) if node.last_heard else "unknown"
|
||||||
|
region = node.region or "Unlocated"
|
||||||
|
lines.append(f" [OFFLINE] {name}: infra offline for {age}, {region}")
|
||||||
|
|
||||||
|
critical_bat = [n for n in health.nodes.values()
|
||||||
|
if n.battery_percent is not None and 0 < n.battery_percent < 10]
|
||||||
|
if critical_bat:
|
||||||
|
problems_found = True
|
||||||
|
for node in sorted(critical_bat, key=lambda n: n.battery_percent):
|
||||||
|
name = _node_display_name(node.long_name, node.short_name, str(node.node_num))
|
||||||
|
region = node.region or "Unlocated"
|
||||||
|
trend = f", trend: {node.battery_trend}" if node.battery_trend else ""
|
||||||
|
lines.append(f" [CRITICAL] {name}: battery {node.battery_percent:.0f}%{trend}, {region}")
|
||||||
|
|
||||||
|
low_bat = [n for n in health.nodes.values()
|
||||||
|
if n.battery_percent is not None and 10 <= n.battery_percent < 20]
|
||||||
|
if low_bat:
|
||||||
|
problems_found = True
|
||||||
|
for node in sorted(low_bat, key=lambda n: n.battery_percent)[:5]:
|
||||||
|
name = _node_display_name(node.long_name, node.short_name, str(node.node_num))
|
||||||
|
region = node.region or "Unlocated"
|
||||||
|
lines.append(f" [LOW BAT] {name}: battery {node.battery_percent:.0f}%, {region}")
|
||||||
|
if len(low_bat) > 5:
|
||||||
|
lines.append(f" ...and {len(low_bat) - 5} more low battery nodes")
|
||||||
|
|
||||||
|
high_util = [n for n in health.nodes.values()
|
||||||
|
if n.channel_utilization is not None and n.channel_utilization > 15]
|
||||||
|
if high_util:
|
||||||
|
problems_found = True
|
||||||
|
for node in sorted(high_util, key=lambda n: n.channel_utilization or 0, reverse=True)[:5]:
|
||||||
|
name = _node_display_name(node.long_name, node.short_name, str(node.node_num))
|
||||||
|
region = node.region or "Unlocated"
|
||||||
|
lines.append(f" [HIGH UTIL] {name}: channel util {node.channel_utilization:.1f}%, {region}")
|
||||||
|
|
||||||
|
single_gw_infra = [n for n in health.nodes.values()
|
||||||
|
if n.is_infrastructure and n.is_online and n.avg_gateways is not None and n.avg_gateways <= 1.0]
|
||||||
|
if single_gw_infra:
|
||||||
|
problems_found = True
|
||||||
|
for node in single_gw_infra:
|
||||||
|
name = _node_display_name(node.long_name, node.short_name, str(node.node_num))
|
||||||
|
region = node.region or "Unlocated"
|
||||||
|
lines.append(f" [1-GW RISK] {name}: infra only reaches 1 gateway, {region}")
|
||||||
|
|
||||||
|
if not problems_found:
|
||||||
|
lines.append(" None - all nodes healthy")
|
||||||
|
|
||||||
# Top Senders section (packets sent = "noisy")
|
|
||||||
top_senders = self.data_store.get_top_senders(5)
|
top_senders = self.data_store.get_top_senders(5)
|
||||||
if top_senders:
|
if top_senders:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Top Senders (24h):")
|
lines.append("TOP SENDERS (24h):")
|
||||||
for node in top_senders:
|
for node in top_senders:
|
||||||
if node.packets_sent_24h > 0:
|
if node.packets_sent_24h > 0:
|
||||||
# Build portnum breakdown with clean names
|
|
||||||
breakdown = []
|
breakdown = []
|
||||||
for portnum, count in sorted(
|
for portnum, count in sorted(node.packets_by_type.items(), key=lambda x: -x[1])[:3]:
|
||||||
node.packets_by_type.items(), key=lambda x: -x[1]
|
breakdown.append(f"{_clean_portnum(portnum)}: {count}")
|
||||||
)[:3]:
|
|
||||||
clean_name = _clean_portnum(portnum)
|
|
||||||
breakdown.append(f"{clean_name}: {count}")
|
|
||||||
breakdown_str = f" ({', '.join(breakdown)})" if breakdown else ""
|
breakdown_str = f" ({', '.join(breakdown)})" if breakdown else ""
|
||||||
display_name = _node_display_name(node.long_name, node.short_name, node.node_id_hex or "")
|
name = _node_display_name(node.long_name, node.short_name, node.node_id_hex or "")
|
||||||
lines.append(
|
region = node.region or ""
|
||||||
f" {display_name}: {node.packets_sent_24h} pkts{breakdown_str}"
|
region_str = f" [{region}]" if region else ""
|
||||||
)
|
lines.append(f" {name}: {node.packets_sent_24h} pkts{breakdown_str}{region_str}")
|
||||||
|
|
||||||
# Device-reported channel utilization (RF airspace busyness)
|
all_nodes = list(health.nodes.values())
|
||||||
util_data = self.data_store.get_mesh_utilization()
|
|
||||||
if util_data["node_count"] > 0:
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Channel Utilization (device-reported RF busyness):")
|
|
||||||
lines.append(f" Mesh avg: {util_data['avg']:.1f}%")
|
|
||||||
lines.append(f" Highest: {util_data['max_node']} at {util_data['max']:.1f}%")
|
|
||||||
|
|
||||||
# Network topology stats (if available)
|
|
||||||
if health.has_traceroute_data:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(
|
|
||||||
f"Routing: {health.traceroute_count} traceroutes, "
|
|
||||||
f"avg {health.avg_hop_count:.1f} hops, max {health.max_hop_count}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# MQTT uplink stats
|
|
||||||
lines.append(f"MQTT Uplinks: {health.uplink_node_count} nodes")
|
|
||||||
|
|
||||||
# Coverage by region - show geographic breakdown
|
|
||||||
# MUST use health.nodes (not data_store.nodes) because region is set by health engine
|
|
||||||
health = self.health_engine.mesh_health
|
|
||||||
all_nodes = list(health.nodes.values()) if health else []
|
|
||||||
nodes_with_gw = [n for n in all_nodes if n.avg_gateways is not None]
|
nodes_with_gw = [n for n in all_nodes if n.avg_gateways is not None]
|
||||||
if nodes_with_gw:
|
if nodes_with_gw:
|
||||||
total_sources = len(self.data_store._sources)
|
total_sources = len(self.data_store._sources)
|
||||||
|
|
@ -302,87 +370,37 @@ class MeshReporter:
|
||||||
single_gw = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.0)
|
single_gw = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.0)
|
||||||
full_gw = sum(1 for n in nodes_with_gw if n.avg_gateways >= total_sources)
|
full_gw = sum(1 for n in nodes_with_gw if n.avg_gateways >= total_sources)
|
||||||
|
|
||||||
lines.append(f"Coverage: {mesh_avg:.1f} avg gw | {full_gw} full | {single_gw} single-gw")
|
|
||||||
|
|
||||||
region_coverage = {}
|
|
||||||
for n in nodes_with_gw:
|
|
||||||
health_node = health.nodes.get(n.node_num)
|
|
||||||
region = health_node.region if health_node else "Unlocated"
|
|
||||||
if not region:
|
|
||||||
region = "Unlocated"
|
|
||||||
region_coverage.setdefault(region, []).append(n.avg_gateways)
|
|
||||||
|
|
||||||
# Sort regions but handle Unlocated separately
|
|
||||||
sorted_regions = sorted(
|
|
||||||
[(r, c) for r, c in region_coverage.items() if r not in ("Unlocated", "", "Unknown", None)],
|
|
||||||
key=lambda x: sum(x[1])/len(x[1])
|
|
||||||
)
|
|
||||||
lines.append(" By region:")
|
|
||||||
for region, counts in sorted_regions[:6]:
|
|
||||||
avg = sum(counts) / len(counts)
|
|
||||||
single = sum(1 for c in counts if c <= 1.0)
|
|
||||||
flag = " !!" if avg < 2.0 else ""
|
|
||||||
single_str = f" ({single} 1-gw)" if single > 0 else ""
|
|
||||||
context = self._region_context(region)
|
|
||||||
context_str = f" ({context})" if context else ""
|
|
||||||
lines.append(f" {region}{context_str}: {len(counts)} nodes, {avg:.1f} avg{single_str}{flag}")
|
|
||||||
# Show unlocated as informational (no coverage recommendations for these)
|
|
||||||
if "Unlocated" in region_coverage:
|
|
||||||
unl = region_coverage["Unlocated"]
|
|
||||||
lines.append(f" Unlocated: {len(unl)} nodes (no GPS — stale or transient)")
|
|
||||||
else:
|
|
||||||
deliver = self.data_store.get_mesh_deliverability()
|
|
||||||
if deliver.get("avg_gateways") is not None:
|
|
||||||
avg_gw = deliver["avg_gateways"]
|
|
||||||
lines.append(f"Coverage: avg {avg_gw:.1f} gateways")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Regions:")
|
|
||||||
|
|
||||||
# Region summaries
|
|
||||||
for region in health.regions:
|
|
||||||
rs = region.score
|
|
||||||
flag = _tier_flag(rs.tier)
|
|
||||||
infra_str = f"{rs.infra_online}/{rs.infra_total} infra"
|
|
||||||
context = self._region_context(region.name)
|
|
||||||
context_str = f" ({context})" if context else ""
|
|
||||||
lines.append(
|
|
||||||
f" {region.name}{context_str}: {rs.composite:.0f}/100 - {infra_str}, {rs.util_percent:.0f}% util{flag}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Top issues
|
|
||||||
issues = self._gather_top_issues(health)
|
|
||||||
if issues:
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Top Issues:")
|
lines.append(f"COVERAGE SUMMARY: {mesh_avg:.1f} avg gateways across {total_sources} sources")
|
||||||
for i, issue in enumerate(issues[:5], 1):
|
lines.append(f" Full coverage ({total_sources}/{total_sources} gw): {full_gw} nodes")
|
||||||
lines.append(f" {i}. {issue}")
|
lines.append(f" Single gateway (1/{total_sources} gw): {single_gw} nodes - at risk if gateway drops")
|
||||||
|
|
||||||
|
if health.has_traceroute_data:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"NETWORK TOPOLOGY: {health.traceroute_count} traceroutes, avg {health.avg_hop_count:.1f} hops, max {health.max_hop_count}")
|
||||||
|
|
||||||
|
lines.append(f"MQTT UPLINKS: {health.uplink_node_count} nodes")
|
||||||
|
|
||||||
# Sensor summary
|
|
||||||
env_nodes = self.data_store.get_sensor_nodes("environment")
|
env_nodes = self.data_store.get_sensor_nodes("environment")
|
||||||
aq_nodes = self.data_store.get_sensor_nodes("air_quality")
|
if env_nodes:
|
||||||
wx_nodes = self.data_store.get_sensor_nodes("weather")
|
|
||||||
if env_nodes or aq_nodes or wx_nodes:
|
|
||||||
lines.append("")
|
|
||||||
sensor_parts = []
|
|
||||||
if env_nodes:
|
|
||||||
sensor_parts.append(f"{len(env_nodes)} env")
|
|
||||||
if aq_nodes:
|
|
||||||
sensor_parts.append(f"{len(aq_nodes)} air quality")
|
|
||||||
if wx_nodes:
|
|
||||||
sensor_parts.append(f"{len(wx_nodes)} weather")
|
|
||||||
lines.append(f"Sensors: {', '.join(sensor_parts)}")
|
|
||||||
|
|
||||||
# Show temp range if available (filter outliers)
|
|
||||||
valid_temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)]
|
valid_temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)]
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"SENSORS: {len(env_nodes)} environment")
|
||||||
if valid_temps:
|
if valid_temps:
|
||||||
lines.append(f" Temp range: {min(valid_temps):.1f}-{max(valid_temps):.1f}C")
|
lines.append(f" Temp range: {min(valid_temps):.1f}-{max(valid_temps):.1f}C")
|
||||||
|
|
||||||
|
pb = self._get_power_breakdown()
|
||||||
|
if pb["total"] > 0:
|
||||||
|
lines.append("")
|
||||||
|
parts = []
|
||||||
|
if pb["usb"]: parts.append(f"{pb['usb']} USB powered")
|
||||||
|
if pb["ok"]: parts.append(f"{pb['ok']} battery ok")
|
||||||
|
if pb["low"]: parts.append(f"{pb['low']} battery low")
|
||||||
|
if pb["critical"]: parts.append(f"{pb['critical']} battery critical")
|
||||||
|
lines.append(f"POWER: {', '.join(parts)}")
|
||||||
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(
|
lines.append(f"TOTAL: {health.total_nodes} nodes across {health.total_regions} regions.")
|
||||||
f"{health.total_nodes} nodes across {health.total_regions} regions. "
|
|
||||||
f"User can ask about any region, locality, or node for details."
|
|
||||||
)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
@ -1073,7 +1091,7 @@ class MeshReporter:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
lines = ["OPTIMIZATION RECOMMENDATIONS:"]
|
lines = ["OPTIMIZATION RECOMMENDATIONS:"]
|
||||||
for rec in recs[:5]:
|
for rec in recs[:10]:
|
||||||
lines.append(f" - {rec}")
|
lines.append(f" - {rec}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
@ -1158,297 +1176,91 @@ class MeshReporter:
|
||||||
return recs
|
return recs
|
||||||
|
|
||||||
def _mesh_recommendations(self, health) -> list[str]:
|
def _mesh_recommendations(self, health) -> list[str]:
|
||||||
"""Generate mesh-wide recommendations with trend awareness."""
|
"""Generate mesh-wide recommendations with specifics."""
|
||||||
recs = []
|
recs = []
|
||||||
|
|
||||||
# Overall utilization
|
# Coverage gaps by region - be SPECIFIC
|
||||||
if health.score.util_percent >= 20:
|
for region in health.regions:
|
||||||
recs.append(
|
region_nodes = []
|
||||||
f"Mesh-wide utilization at {health.score.util_percent:.0f}%. "
|
for nid_str in region.node_ids:
|
||||||
f"Consider reducing position/telemetry broadcast frequency."
|
try:
|
||||||
)
|
nid = int(nid_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.avg_gateways is not None:
|
||||||
|
region_nodes.append(node)
|
||||||
|
|
||||||
# Traffic trend recommendation
|
if not region_nodes:
|
||||||
trend = self._get_traffic_trend_summary()
|
continue
|
||||||
if "up" in trend and "15" in trend: # Significant increase
|
|
||||||
recs.append(
|
|
||||||
f"Traffic {trend}. Review recently added nodes or changed settings."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Multiple regions with issues
|
avg_gw = sum(n.avg_gateways for n in region_nodes) / len(region_nodes)
|
||||||
problem_regions = [r for r in health.regions if r.score.composite < 75]
|
single_gw = [n for n in region_nodes if n.avg_gateways <= 1.0]
|
||||||
if len(problem_regions) > 1:
|
offline_infra = [n for n in region_nodes if n.is_infrastructure and not n.is_online]
|
||||||
names = ", ".join(r.name for r in problem_regions[:3])
|
|
||||||
recs.append(
|
|
||||||
f"Multiple regions degraded ({names}). Prioritize infrastructure improvements."
|
|
||||||
)
|
|
||||||
|
|
||||||
# High packet nodes mesh-wide
|
context = self._region_context(region.name)
|
||||||
flagged = self.health_engine.get_flagged_nodes()
|
region_label = f"{region.name} ({context})" if context else region.name
|
||||||
if len(flagged) > 3:
|
|
||||||
total_excess = sum(
|
|
||||||
n.non_text_packets - self.health_engine.packet_threshold for n in flagged
|
|
||||||
)
|
|
||||||
recs.append(
|
|
||||||
f"{len(flagged)} nodes exceeding packet threshold ({total_excess} excess packets/day). "
|
|
||||||
f"Review default telemetry intervals."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Battery warnings (exclude USB-powered)
|
# Single-gateway concentration
|
||||||
battery_warnings = [
|
if len(single_gw) >= 3:
|
||||||
n for n in self.health_engine.get_battery_warnings()
|
|
||||||
if n.battery_percent is not None and n.battery_percent <= 100
|
|
||||||
]
|
|
||||||
if len(battery_warnings) > 2:
|
|
||||||
recs.append(
|
|
||||||
f"{len(battery_warnings)} nodes with low battery. "
|
|
||||||
f"Consider solar additions for remote nodes."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Hop count recommendation from traceroutes
|
|
||||||
if health.has_traceroute_data:
|
|
||||||
if health.avg_hop_count > 4:
|
|
||||||
recs.append(
|
recs.append(
|
||||||
f"Average hop count {health.avg_hop_count:.1f} is high. "
|
f"Coverage gap in {region_label}: {len(single_gw)} nodes only reach 1 gateway. "
|
||||||
f"Consider adding infrastructure to reduce latency."
|
f"A new MQTT feeder in this area would add monitoring redundancy."
|
||||||
)
|
|
||||||
elif health.max_hop_count > 6:
|
|
||||||
recs.append(
|
|
||||||
f"Max hop count {health.max_hop_count} indicates long routes. "
|
|
||||||
f"Strategic node placement could improve reach."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# MQTT uplink coverage
|
# Offline infrastructure
|
||||||
if health.uplink_node_count == 0:
|
if offline_infra:
|
||||||
total_infra = sum(1 for n in health.nodes.values() if n.is_infrastructure)
|
names = ", ".join(_node_display_name(n.long_name, n.short_name, str(n.node_num)) for n in offline_infra[:3])
|
||||||
if total_infra > 0:
|
|
||||||
recs.append(
|
recs.append(
|
||||||
"No MQTT uplinks detected. Enable on infrastructure nodes for better mesh visibility."
|
f"{region_label}: {len(offline_infra)} infrastructure offline ({names}). "
|
||||||
)
|
f"Restoring these would improve routing and coverage."
|
||||||
elif health.total_regions > 0:
|
|
||||||
uplinks_per_region = health.uplink_node_count / health.total_regions
|
|
||||||
if uplinks_per_region < 1:
|
|
||||||
recs.append(
|
|
||||||
f"Only {health.uplink_node_count} MQTT uplinks across "
|
|
||||||
f"{health.total_regions} regions. Consider adding redundancy."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mesh-wide deliverability/coverage
|
# Battery predictions
|
||||||
|
critical = [n for n in health.nodes.values()
|
||||||
|
if n.battery_percent is not None and 0 < n.battery_percent < 10]
|
||||||
|
for node in critical[:3]:
|
||||||
|
name = _node_display_name(node.long_name, node.short_name, str(node.node_num))
|
||||||
|
trend = f" and {node.battery_trend}" if node.battery_trend else ""
|
||||||
|
recs.append(f"{name} at {node.battery_percent:.0f}% battery{trend}. Likely offline soon.")
|
||||||
|
|
||||||
|
# High utilization
|
||||||
|
high_util = [n for n in health.nodes.values()
|
||||||
|
if n.channel_utilization is not None and n.channel_utilization > 18]
|
||||||
|
if high_util:
|
||||||
|
names = ", ".join(_node_display_name(n.long_name, n.short_name, str(n.node_num)) for n in high_util[:3])
|
||||||
|
recs.append(
|
||||||
|
f"High channel utilization on {names}. "
|
||||||
|
f"Check for aggressive broadcast intervals or nearby interference."
|
||||||
|
)
|
||||||
|
|
||||||
|
# MQTT uplink gaps
|
||||||
|
for region in health.regions:
|
||||||
|
infra_in_region = []
|
||||||
|
for nid_str in region.node_ids:
|
||||||
|
try:
|
||||||
|
nid = int(nid_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
node = health.nodes.get(nid)
|
||||||
|
if node and node.is_infrastructure:
|
||||||
|
infra_in_region.append(node)
|
||||||
|
|
||||||
|
uplinks = [n for n in infra_in_region if n and n.uplink_enabled]
|
||||||
|
if infra_in_region and not uplinks:
|
||||||
|
context = self._region_context(region.name)
|
||||||
|
recs.append(
|
||||||
|
f"No MQTT uplinks in {region.name} ({context}). "
|
||||||
|
f"Enable on at least one infrastructure node for monitoring visibility."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Overall deliverability
|
||||||
deliver = self.data_store.get_mesh_deliverability()
|
deliver = self.data_store.get_mesh_deliverability()
|
||||||
if deliver.get("avg_gateways") is not None:
|
if deliver.get("avg_gateways") is not None and deliver["avg_gateways"] < 2.0:
|
||||||
avg_gw = deliver["avg_gateways"]
|
recs.append(
|
||||||
if avg_gw < 1.5:
|
f"Mesh-wide average is {deliver['avg_gateways']:.1f} gateways per packet. "
|
||||||
recs.append(
|
f"Adding MQTT feeders would improve monitoring reliability across the mesh."
|
||||||
f"Mesh-wide average is {avg_gw:.1f} gateways per packet. "
|
)
|
||||||
f"Adding MQTT feeders would improve monitoring reliability."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Coverage gaps
|
|
||||||
if hasattr(self.data_store, "get_coverage_gaps"):
|
|
||||||
gaps = self.data_store.get_coverage_gaps()
|
|
||||||
if gaps:
|
|
||||||
gap_regions = {}
|
|
||||||
for g in gaps:
|
|
||||||
node_num = g.get("node_num")
|
|
||||||
health_node = health.nodes.get(node_num) if node_num else None
|
|
||||||
region = health_node.region if health_node else "Unknown"
|
|
||||||
gap_regions.setdefault(region or "Unknown", []).append(g)
|
|
||||||
|
|
||||||
for region, nodes in sorted(gap_regions.items(), key=lambda x: -len(x[1])):
|
|
||||||
if len(nodes) >= 3:
|
|
||||||
recs.append(
|
|
||||||
f"{region}: {len(nodes)} nodes with thin coverage. "
|
|
||||||
f"A new gateway here would improve monitoring."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
return recs
|
return recs
|
||||||
|
|
||||||
def build_lora_compact(self, scope: str, scope_value: str = None) -> str:
|
|
||||||
"""Build LoRa-optimized compact summary (~200 chars)."""
|
|
||||||
health = self.health_engine.mesh_health
|
|
||||||
if not health:
|
|
||||||
return "Mesh: No data"
|
|
||||||
|
|
||||||
if scope == "region" and scope_value:
|
|
||||||
region = self._find_region(scope_value)
|
|
||||||
if not region:
|
|
||||||
return f"Region '{scope_value}' not found"
|
|
||||||
rs = region.score
|
|
||||||
return (
|
|
||||||
f"{region.name} {rs.composite:.0f}/100 | "
|
|
||||||
f"{rs.infra_online}/{rs.infra_total} infra | {rs.util_percent:.0f}% util"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mesh summary
|
|
||||||
s = health.score
|
|
||||||
lines = [
|
|
||||||
f"Mesh {s.composite:.0f}/100 | {s.infra_online}/{s.infra_total} infra | {s.util_percent:.0f}% util"
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add warnings for problem regions/nodes
|
|
||||||
warnings = []
|
|
||||||
for region in health.regions:
|
|
||||||
if region.score.composite < 60:
|
|
||||||
offline = region.score.infra_total - region.score.infra_online
|
|
||||||
warnings.append(
|
|
||||||
f"! {region.name} {region.score.composite:.0f}/100 - {offline} infra offline"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Battery warnings (skip USB-powered)
|
|
||||||
battery_warnings = [
|
|
||||||
n for n in self.health_engine.get_battery_warnings()
|
|
||||||
if n.battery_percent is not None and n.battery_percent <= 100
|
|
||||||
]
|
|
||||||
for node in battery_warnings[:2]:
|
|
||||||
name = node.short_name or node.node_id_hex[:4]
|
|
||||||
warnings.append(
|
|
||||||
f"! {name} bat {node.battery_percent:.0f}%"
|
|
||||||
)
|
|
||||||
|
|
||||||
for w in warnings[:2]:
|
|
||||||
lines.append(w)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def build_node_compact(self, node_identifier: str) -> str:
|
|
||||||
"""Build compact node status for subscription DMs (~200 chars)."""
|
|
||||||
health = self.health_engine.mesh_health
|
|
||||||
if not health:
|
|
||||||
return "Node: No data"
|
|
||||||
|
|
||||||
node = self._find_node(node_identifier)
|
|
||||||
if not node:
|
|
||||||
return f"Node '{node_identifier}' not found"
|
|
||||||
|
|
||||||
# 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_heard)
|
|
||||||
|
|
||||||
parts = [f"{display_name} [{status}]"]
|
|
||||||
|
|
||||||
# Battery (skip USB)
|
|
||||||
if node.battery_percent is not None:
|
|
||||||
if node.battery_percent > 100:
|
|
||||||
parts.append("USB")
|
|
||||||
else:
|
|
||||||
parts.append(f"bat {node.battery_percent:.0f}%")
|
|
||||||
|
|
||||||
# Last seen
|
|
||||||
parts.append(f"seen {age}")
|
|
||||||
|
|
||||||
# Traffic
|
|
||||||
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 node.neighbor_count > 0:
|
|
||||||
parts.append(f"{node.neighbor_count} nbrs")
|
|
||||||
|
|
||||||
line1 = " | ".join(parts)
|
|
||||||
|
|
||||||
# Warnings if any
|
|
||||||
warnings = []
|
|
||||||
if not node.is_online:
|
|
||||||
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.packets_sent_24h - node.text_messages_24h) > self.health_engine.packet_threshold:
|
|
||||||
warnings.append("! HIGH TRAFFIC")
|
|
||||||
|
|
||||||
if warnings:
|
|
||||||
return f"{line1}\n{' '.join(warnings)}"
|
|
||||||
return line1
|
|
||||||
|
|
||||||
def build_region_compact(self, region_name: str) -> str:
|
|
||||||
"""Build compact region status for subscription DMs (~200 chars)."""
|
|
||||||
return self.build_lora_compact(scope="region", scope_value=region_name)
|
|
||||||
|
|
||||||
def _find_region(self, name: str) -> Optional["RegionHealth"]:
|
|
||||||
"""Find a region by fuzzy name match."""
|
|
||||||
health = self.health_engine.mesh_health
|
|
||||||
if not health:
|
|
||||||
return None
|
|
||||||
|
|
||||||
name_lower = name.lower().strip()
|
|
||||||
|
|
||||||
# Exact match first
|
|
||||||
for region in health.regions:
|
|
||||||
if region.name.lower() == name_lower:
|
|
||||||
return region
|
|
||||||
|
|
||||||
# Substring match
|
|
||||||
for region in health.regions:
|
|
||||||
if name_lower in region.name.lower():
|
|
||||||
return region
|
|
||||||
|
|
||||||
# Try matching against anchor city names
|
|
||||||
for anchor in self.health_engine.regions:
|
|
||||||
anchor_name_lower = anchor.name.lower()
|
|
||||||
if name_lower in anchor_name_lower:
|
|
||||||
for region in health.regions:
|
|
||||||
if region.name == anchor.name:
|
|
||||||
return region
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
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:
|
|
||||||
return None
|
|
||||||
|
|
||||||
identifier = identifier.strip()
|
|
||||||
id_lower = identifier.lower()
|
|
||||||
|
|
||||||
# Try shortname (case-insensitive)
|
|
||||||
for node in health.nodes.values():
|
|
||||||
if node.short_name and node.short_name.lower() == id_lower:
|
|
||||||
return node
|
|
||||||
|
|
||||||
# Try longname (substring)
|
|
||||||
for node in health.nodes.values():
|
|
||||||
if node.long_name and id_lower in node.long_name.lower():
|
|
||||||
return node
|
|
||||||
|
|
||||||
# Try exact nodeId
|
|
||||||
if identifier in health.nodes:
|
|
||||||
return health.nodes[identifier]
|
|
||||||
|
|
||||||
# Try hex nodeId with ! prefix
|
|
||||||
if identifier.startswith("!"):
|
|
||||||
hex_id = identifier[1:]
|
|
||||||
for nid, node in health.nodes.items():
|
|
||||||
if nid.lower() == hex_id.lower():
|
|
||||||
return node
|
|
||||||
|
|
||||||
# Try decimal nodeNum
|
|
||||||
if identifier.isdigit():
|
|
||||||
node_num = int(identifier)
|
|
||||||
node_id = str(node_num)
|
|
||||||
if node_num in health.nodes:
|
|
||||||
return health.nodes[node_num]
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def list_regions_compact(self) -> str:
|
|
||||||
"""List all regions with scores in compact format."""
|
|
||||||
health = self.health_engine.mesh_health
|
|
||||||
if not health or not health.regions:
|
|
||||||
return "No regions configured."
|
|
||||||
|
|
||||||
lines = ["Regions:"]
|
|
||||||
for region in health.regions:
|
|
||||||
s = region.score
|
|
||||||
flag = _tier_flag(s.tier)
|
|
||||||
lines.append(f" {region.name}: {s.composite:.0f}/100{flag}")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
|
||||||
|
|
@ -95,38 +95,27 @@ _MESH_PHRASES = [
|
||||||
_MESH_AWARENESS_PROMPT = """
|
_MESH_AWARENESS_PROMPT = """
|
||||||
MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions):
|
MESH DATA RESPONSE RULES (OVERRIDE brevity rules for mesh/network questions):
|
||||||
|
|
||||||
|
The data blocks above contain detailed information about every region, infrastructure node,
|
||||||
|
coverage gap, and problem node on the mesh. USE THIS DATA in your response.
|
||||||
|
|
||||||
RESPONSE STYLE:
|
RESPONSE STYLE:
|
||||||
- Give DETAILED, data-driven responses with actual numbers
|
- DETAILED, data-driven responses. Reference specific node names, scores, gateway counts.
|
||||||
- Talk in GEOGRAPHIC terms — use the local names and cities from REGION GEOGRAPHY below
|
- Use LOCAL NAMES from the region descriptions (Magic Valley, Treasure Valley, etc.)
|
||||||
- Name specific infrastructure nodes by their long name
|
- When discussing infrastructure, name the actual nodes (Mount Harrison Router, not just "5 infra")
|
||||||
- Include scores, percentages, node counts, battery levels, gateway counts
|
- When discussing coverage gaps, explain WHERE and HOW MANY nodes are affected
|
||||||
- You CAN use 3-5 messages if needed — LoRa chunking handles splitting
|
- When discussing problems, name the node and explain the impact
|
||||||
- No markdown formatting — plain text only
|
- You CAN use 3-5 messages. Keep each sentence under 150 characters.
|
||||||
- CRITICAL: Keep every sentence under 150 characters. Break long thoughts into multiple short sentences. The message system handles multiple sentences perfectly but will truncate a single long sentence.
|
- No markdown formatting - plain text only
|
||||||
|
|
||||||
ANSWERING COVERAGE QUESTIONS:
|
QUESTION TYPES:
|
||||||
- Reference geographic areas by local name from the region config
|
- "How's the mesh?" -> Lead with composite score. Highlight 1-2 biggest issues by name. Summarize each region briefly.
|
||||||
- Name infrastructure nodes by long name
|
- "Where do we need coverage?" -> Name regions with single-gateway nodes. Name offline infra. Suggest specific locations.
|
||||||
- Give avg gateways AND explain meaning
|
- "Tell me about [node]" -> Give full detail from the data above.
|
||||||
- Identify single-gateway nodes as risks
|
- "How is [region]?" -> Give that region's infrastructure status, coverage, issues.
|
||||||
- For "where do we need infrastructure" — name the geographic area, not "Unknown"
|
- "What's wrong?" -> List problem nodes by name with specifics.
|
||||||
- Do NOT recommend infrastructure for Unlocated nodes
|
|
||||||
|
|
||||||
ANSWERING NODE QUESTIONS:
|
IMPORTANT: Do NOT lump different regions together. Each is a distinct area.
|
||||||
- Include: hardware, role, region with local name, battery, channel util, TX airtime, coverage, neighbors, traffic breakdown
|
Do NOT recommend infrastructure for "Unlocated" nodes - they have no known position.
|
||||||
- Compare metrics to thresholds
|
|
||||||
|
|
||||||
ANSWERING MESH OVERVIEW:
|
|
||||||
- Lead with composite score and 5 pillars
|
|
||||||
- Break down by region using local names
|
|
||||||
- Highlight problems with specific nodes/areas
|
|
||||||
|
|
||||||
ABOUT UNLOCATED NODES:
|
|
||||||
- Nodes with no GPS and no neighbor data cannot be placed on the map
|
|
||||||
- Do NOT recommend infrastructure for these areas
|
|
||||||
- Focus coverage gaps on located regions only
|
|
||||||
|
|
||||||
IMPORTANT: Do NOT lump different regions together just because they share directional words. Each region is a distinct geographic area.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue