mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
feat: !health colored dots no numbers, distance from AIDA, router distance detection
!health: 🔵 perfect, 🟢 healthy, 🟠 warning, 🔴 critical — no /100 scores. Each line ends with period for separate LoRa messages. Uses long_name to avoid emoji shortnames (📡 → TVM Tablerock Relay). Distance from AIDA shown on every infra node in Tier 1. Router detects how far questions and injects computed distance. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5be1d20b24
commit
5839fdeb18
2 changed files with 110 additions and 34 deletions
|
|
@ -282,6 +282,11 @@ class MeshReporter:
|
||||||
|
|
||||||
score = health.score
|
score = health.score
|
||||||
|
|
||||||
|
# Get AIDA's position for distance calculations
|
||||||
|
aida_node = health.nodes.get(0x27780c47) # AIDA-N2
|
||||||
|
aida_lat = aida_node.latitude if aida_node else None
|
||||||
|
aida_lon = aida_node.longitude if aida_node else None
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"LIVE MESH HEALTH DATA (as of {age_str}):",
|
f"LIVE MESH HEALTH DATA (as of {age_str}):",
|
||||||
"",
|
"",
|
||||||
|
|
@ -344,6 +349,10 @@ class MeshReporter:
|
||||||
if node.uplink_enabled:
|
if node.uplink_enabled:
|
||||||
parts.append("MQTT")
|
parts.append("MQTT")
|
||||||
|
|
||||||
|
if aida_lat and aida_lon and node.latitude and node.longitude and node.node_num != 0x27780c47:
|
||||||
|
km = _haversine_km(node.latitude, node.longitude, aida_lat, aida_lon)
|
||||||
|
parts.append(f"{_format_distance(km)} from AIDA")
|
||||||
|
|
||||||
lines.append(f" [{status}] {name}: {', '.join(parts)}")
|
lines.append(f" [{status}] {name}: {', '.join(parts)}")
|
||||||
else:
|
else:
|
||||||
lines.append(f" Infrastructure: none in region")
|
lines.append(f" Infrastructure: none in region")
|
||||||
|
|
@ -1511,25 +1520,33 @@ class MeshReporter:
|
||||||
|
|
||||||
s = health.score
|
s = health.score
|
||||||
|
|
||||||
if s.composite >= 90:
|
# Color dot — no numbers shown
|
||||||
header = f"📡 freq51 looking great — {s.composite:.0f}/100"
|
if s.composite >= 100:
|
||||||
|
dot = "🔵"
|
||||||
|
mood = "perfect"
|
||||||
elif s.composite >= 75:
|
elif s.composite >= 75:
|
||||||
header = f"📡 freq51 running solid — {s.composite:.0f}/100"
|
dot = "🟢"
|
||||||
elif s.composite >= 60:
|
mood = "healthy"
|
||||||
header = f"📡 freq51 needs attention — {s.composite:.0f}/100"
|
elif s.composite >= 50:
|
||||||
|
dot = "🟠"
|
||||||
|
mood = "needs attention"
|
||||||
else:
|
else:
|
||||||
header = f"🚨 freq51 struggling — {s.composite:.0f}/100"
|
dot = "🔴"
|
||||||
|
mood = "critical"
|
||||||
|
|
||||||
lines = [header, ""]
|
# Each line ends with period so chunker sends each as separate message
|
||||||
|
lines = [f"📡 freq51 {dot} {mood}."]
|
||||||
|
|
||||||
offline_infra = [n for n in health.nodes.values() if n.is_infrastructure and not n.is_online]
|
offline_infra = [n for n in health.nodes.values() if n.is_infrastructure and not n.is_online]
|
||||||
if offline_infra:
|
if offline_infra:
|
||||||
offline_names = ", ".join(n.short_name or str(n.node_num) for n in offline_infra[:3])
|
offline_names = ", ".join(
|
||||||
|
(n.long_name or n.short_name or str(n.node_num)) for n in offline_infra[:3]
|
||||||
|
)
|
||||||
more = f" +{len(offline_infra)-3}" if len(offline_infra) > 3 else ""
|
more = f" +{len(offline_infra)-3}" if len(offline_infra) > 3 else ""
|
||||||
lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up")
|
lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up.")
|
||||||
lines.append(f" ❌ Down: {offline_names}{more}")
|
lines.append(f"❌ Down: {offline_names}{more}.")
|
||||||
else:
|
else:
|
||||||
lines.append(f"🏗️ All {s.infra_total} routers up ✅")
|
lines.append(f"🏗️ All {s.infra_total} routers up ✅.")
|
||||||
|
|
||||||
nodes_with_gw = [n for n in health.nodes.values() if n.avg_gateways is not None]
|
nodes_with_gw = [n for n in health.nodes.values() if n.avg_gateways is not None]
|
||||||
if nodes_with_gw:
|
if nodes_with_gw:
|
||||||
|
|
@ -1537,31 +1554,31 @@ class MeshReporter:
|
||||||
full = sum(1 for n in nodes_with_gw if n.avg_gateways >= total_sources)
|
full = sum(1 for n in nodes_with_gw if n.avg_gateways >= total_sources)
|
||||||
single = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.0)
|
single = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.0)
|
||||||
if single > 0:
|
if single > 0:
|
||||||
lines.append(f"📶 {full} nodes full coverage, {single} on thin ice with 1 gw")
|
lines.append(f"📶 {full} nodes full coverage, {single} on thin ice with 1 gw.")
|
||||||
else:
|
else:
|
||||||
lines.append(f"📶 {full} nodes full coverage ✅")
|
lines.append(f"📶 {full} nodes full coverage ✅.")
|
||||||
|
|
||||||
high_util = [n for n in health.nodes.values()
|
high_util = [n for n in health.nodes.values()
|
||||||
if n.channel_utilization is not None and n.channel_utilization > 15]
|
if n.channel_utilization is not None and n.channel_utilization > 15]
|
||||||
if high_util:
|
if high_util:
|
||||||
worst = max(high_util, key=lambda n: n.channel_utilization)
|
worst = max(high_util, key=lambda n: n.channel_utilization)
|
||||||
lines.append(f"🔥 {worst.short_name} running hot at {worst.channel_utilization:.0f}% util")
|
worst_name = worst.long_name or worst.short_name or str(worst.node_num)
|
||||||
|
lines.append(f"🔥 {worst_name} running hot at {worst.channel_utilization:.0f}% util.")
|
||||||
|
|
||||||
infra_nodes = [n for n in health.nodes.values() if n.is_infrastructure]
|
infra_nodes = [n for n in health.nodes.values() if n.is_infrastructure]
|
||||||
bat_low = [n for n in infra_nodes if n.battery_percent is not None and 0 < n.battery_percent < 20]
|
bat_low = [n for n in infra_nodes if n.battery_percent is not None and 0 < n.battery_percent < 20]
|
||||||
if bat_low:
|
if bat_low:
|
||||||
low_names = ", ".join(n.short_name for n in bat_low)
|
low_names = ", ".join(n.long_name or n.short_name or str(n.node_num) for n in bat_low)
|
||||||
lines.append(f"🔋 ⚠️ {low_names} low battery")
|
lines.append(f"🔋 ⚠️ {low_names} low battery.")
|
||||||
else:
|
else:
|
||||||
lines.append(f"🔋 All infra powered up ✅")
|
lines.append(f"🔋 All infra powered up ✅.")
|
||||||
|
|
||||||
env_nodes = [n for n in health.nodes.values() if n.has_environment_sensor]
|
env_nodes = [n for n in health.nodes.values() if n.has_environment_sensor]
|
||||||
if env_nodes:
|
if env_nodes:
|
||||||
temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)]
|
temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)]
|
||||||
if temps:
|
if temps:
|
||||||
lines.append(f"🌡️ {min(temps):.0f}–{max(temps):.0f}°C across {len(env_nodes)} sensors")
|
lines.append(f"🌡️ {min(temps):.0f}–{max(temps):.0f}°C across {len(env_nodes)} sensors.")
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
region_parts = []
|
region_parts = []
|
||||||
for region in health.regions:
|
for region in health.regions:
|
||||||
if not region.node_ids:
|
if not region.node_ids:
|
||||||
|
|
@ -1569,14 +1586,16 @@ class MeshReporter:
|
||||||
rs = region.score
|
rs = region.score
|
||||||
context = self._region_context(region.name)
|
context = self._region_context(region.name)
|
||||||
name = context.split("(")[0].strip() if context else region.name
|
name = context.split("(")[0].strip() if context else region.name
|
||||||
if rs.composite >= 90:
|
if rs.composite >= 100:
|
||||||
region_parts.append(f"{name} ✅")
|
region_parts.append(f"{name} 🔵")
|
||||||
elif rs.composite >= 70:
|
elif rs.composite >= 75:
|
||||||
region_parts.append(f"{name} ⚠️")
|
region_parts.append(f"{name} 🟢")
|
||||||
|
elif rs.composite >= 50:
|
||||||
|
region_parts.append(f"{name} 🟠")
|
||||||
else:
|
else:
|
||||||
region_parts.append(f"{name} ❌")
|
region_parts.append(f"{name} 🔴")
|
||||||
if region_parts:
|
if region_parts:
|
||||||
lines.append(" | ".join(region_parts))
|
lines.append(" | ".join(region_parts) + ".")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
@ -1586,15 +1605,17 @@ class MeshReporter:
|
||||||
context = self._region_context(region.name)
|
context = self._region_context(region.name)
|
||||||
name = context.split("(")[0].strip() if context else region.name
|
name = context.split("(")[0].strip() if context else region.name
|
||||||
|
|
||||||
if rs.composite >= 90:
|
if rs.composite >= 100:
|
||||||
header = f"📡 {name} looking good — {rs.composite:.0f}/100"
|
dot = "🔵"
|
||||||
elif rs.composite >= 70:
|
elif rs.composite >= 75:
|
||||||
header = f"📡 {name} needs attention — {rs.composite:.0f}/100"
|
dot = "🟢"
|
||||||
|
elif rs.composite >= 50:
|
||||||
|
dot = "🟠"
|
||||||
else:
|
else:
|
||||||
header = f"🚨 {name} in trouble — {rs.composite:.0f}/100"
|
dot = "🔴"
|
||||||
|
|
||||||
lines = [header]
|
lines = [f"📡 {name} {dot}."]
|
||||||
lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | 📡 {rs.util_percent:.0f}% util")
|
lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | 📡 {rs.util_percent:.0f}% util.")
|
||||||
|
|
||||||
offline = []
|
offline = []
|
||||||
for nid_str in region.node_ids:
|
for nid_str in region.node_ids:
|
||||||
|
|
@ -1604,9 +1625,9 @@ class MeshReporter:
|
||||||
continue
|
continue
|
||||||
node = self.health_engine.mesh_health.nodes.get(nid)
|
node = self.health_engine.mesh_health.nodes.get(nid)
|
||||||
if node and node.is_infrastructure and not node.is_online:
|
if node and node.is_infrastructure and not node.is_online:
|
||||||
offline.append(node.short_name or str(nid))
|
offline.append(node.long_name or node.short_name or str(nid))
|
||||||
if offline:
|
if offline:
|
||||||
lines.append(f" ❌ Down: {', '.join(offline)}")
|
lines.append(f"❌ Down: {', '.join(offline)}.")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,54 @@ class MessageRouter:
|
||||||
ctx["last_was_mesh"] = False
|
ctx["last_was_mesh"] = False
|
||||||
ctx["last_scope"] = ("mesh", None)
|
ctx["last_scope"] = ("mesh", None)
|
||||||
|
|
||||||
|
def _try_compute_distance(self, query: str) -> str:
|
||||||
|
"""Extract two node names from a distance question and compute distance."""
|
||||||
|
if not self.mesh_reporter:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
health = self.mesh_reporter.health_engine.mesh_health
|
||||||
|
if not health:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
query_lower = query.lower()
|
||||||
|
|
||||||
|
# Build name -> node lookup
|
||||||
|
node_names = {}
|
||||||
|
for node in health.nodes.values():
|
||||||
|
if node.short_name:
|
||||||
|
node_names[node.short_name.lower()] = node
|
||||||
|
if node.long_name:
|
||||||
|
node_names[node.long_name.lower()] = node
|
||||||
|
|
||||||
|
# AIDA aliases
|
||||||
|
aida_node = health.nodes.get(0x27780c47)
|
||||||
|
if aida_node:
|
||||||
|
for alias in ["aida", "aida-n2", "me", "my node", "yourself", "your position", "you"]:
|
||||||
|
node_names[alias] = aida_node
|
||||||
|
|
||||||
|
# Find mentioned nodes (longest names first)
|
||||||
|
found_nodes = []
|
||||||
|
for name in sorted(node_names.keys(), key=len, reverse=True):
|
||||||
|
if name in query_lower and len(name) >= 2:
|
||||||
|
node = node_names[name]
|
||||||
|
if not any(n.node_num == node.node_num for n in found_nodes):
|
||||||
|
found_nodes.append(node)
|
||||||
|
if len(found_nodes) >= 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(found_nodes) == 2:
|
||||||
|
return self.mesh_reporter.build_distance(
|
||||||
|
str(found_nodes[0].node_num),
|
||||||
|
str(found_nodes[1].node_num)
|
||||||
|
)
|
||||||
|
elif len(found_nodes) == 1 and aida_node:
|
||||||
|
return self.mesh_reporter.build_distance(
|
||||||
|
str(found_nodes[0].node_num),
|
||||||
|
str(aida_node.node_num)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
async def generate_llm_response(self, message: MeshMessage, query: str) -> str:
|
||||||
"""Generate LLM response for a message.
|
"""Generate LLM response for a message.
|
||||||
|
|
||||||
|
|
@ -642,6 +690,13 @@ class MessageRouter:
|
||||||
# DEBUG: Log system prompt status
|
# DEBUG: Log system prompt status
|
||||||
logger.debug(f"System prompt length: {len(system_prompt)} chars")
|
logger.debug(f"System prompt length: {len(system_prompt)} chars")
|
||||||
|
|
||||||
|
# Detect distance questions and inject computed distance
|
||||||
|
distance_keywords = ["how far", "distance", "how close", "miles from", "km from", "away from"]
|
||||||
|
if any(kw in query.lower() for kw in distance_keywords):
|
||||||
|
distance_result = self._try_compute_distance(query)
|
||||||
|
if distance_result:
|
||||||
|
system_prompt += f"\n\nDISTANCE CALCULATION:\n{distance_result}\n"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self.llm.generate(
|
response = await self.llm.generate(
|
||||||
messages=history,
|
messages=history,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue