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:
K7ZVX 2026-05-05 23:47:48 +00:00
commit 5839fdeb18
2 changed files with 110 additions and 34 deletions

View file

@ -282,6 +282,11 @@ class MeshReporter:
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 = [
f"LIVE MESH HEALTH DATA (as of {age_str}):",
"",
@ -344,6 +349,10 @@ class MeshReporter:
if node.uplink_enabled:
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)}")
else:
lines.append(f" Infrastructure: none in region")
@ -1511,25 +1520,33 @@ class MeshReporter:
s = health.score
if s.composite >= 90:
header = f"📡 freq51 looking great — {s.composite:.0f}/100"
# Color dot — no numbers shown
if s.composite >= 100:
dot = "🔵"
mood = "perfect"
elif s.composite >= 75:
header = f"📡 freq51 running solid — {s.composite:.0f}/100"
elif s.composite >= 60:
header = f"📡 freq51 needs attention — {s.composite:.0f}/100"
dot = "🟢"
mood = "healthy"
elif s.composite >= 50:
dot = "🟠"
mood = "needs attention"
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]
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 ""
lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up")
lines.append(f" ❌ Down: {offline_names}{more}")
lines.append(f"🏗️ {s.infra_online}/{s.infra_total} routers up.")
lines.append(f"❌ Down: {offline_names}{more}.")
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]
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)
single = sum(1 for n in nodes_with_gw if n.avg_gateways <= 1.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:
lines.append(f"📶 {full} nodes full coverage ✅")
lines.append(f"📶 {full} nodes full coverage ✅.")
high_util = [n for n in health.nodes.values()
if n.channel_utilization is not None and n.channel_utilization > 15]
if high_util:
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]
bat_low = [n for n in infra_nodes if n.battery_percent is not None and 0 < n.battery_percent < 20]
if bat_low:
low_names = ", ".join(n.short_name for n in bat_low)
lines.append(f"🔋 ⚠️ {low_names} low battery")
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.")
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]
if env_nodes:
temps = [n.temperature for n in env_nodes if _is_valid_temperature(n.temperature)]
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 = []
for region in health.regions:
if not region.node_ids:
@ -1569,14 +1586,16 @@ class MeshReporter:
rs = region.score
context = self._region_context(region.name)
name = context.split("(")[0].strip() if context else region.name
if rs.composite >= 90:
region_parts.append(f"{name}")
elif rs.composite >= 70:
region_parts.append(f"{name} ⚠️")
if rs.composite >= 100:
region_parts.append(f"{name} 🔵")
elif rs.composite >= 75:
region_parts.append(f"{name} 🟢")
elif rs.composite >= 50:
region_parts.append(f"{name} 🟠")
else:
region_parts.append(f"{name}")
region_parts.append(f"{name} 🔴")
if region_parts:
lines.append(" | ".join(region_parts))
lines.append(" | ".join(region_parts) + ".")
return "\n".join(lines)
@ -1586,15 +1605,17 @@ class MeshReporter:
context = self._region_context(region.name)
name = context.split("(")[0].strip() if context else region.name
if rs.composite >= 90:
header = f"📡 {name} looking good — {rs.composite:.0f}/100"
elif rs.composite >= 70:
header = f"📡 {name} needs attention — {rs.composite:.0f}/100"
if rs.composite >= 100:
dot = "🔵"
elif rs.composite >= 75:
dot = "🟢"
elif rs.composite >= 50:
dot = "🟠"
else:
header = f"🚨 {name} in trouble — {rs.composite:.0f}/100"
dot = "🔴"
lines = [header]
lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | 📡 {rs.util_percent:.0f}% util")
lines = [f"📡 {name} {dot}."]
lines.append(f"🏗️ {rs.infra_online}/{rs.infra_total} infra | 📡 {rs.util_percent:.0f}% util.")
offline = []
for nid_str in region.node_ids:
@ -1604,9 +1625,9 @@ class MeshReporter:
continue
node = self.health_engine.mesh_health.nodes.get(nid)
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:
lines.append(f" ❌ Down: {', '.join(offline)}")
lines.append(f"❌ Down: {', '.join(offline)}.")
return "\n".join(lines)

View file

@ -465,6 +465,54 @@ class MessageRouter:
ctx["last_was_mesh"] = False
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:
"""Generate LLM response for a message.
@ -642,6 +690,13 @@ class MessageRouter:
# DEBUG: Log system prompt status
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:
response = await self.llm.generate(
messages=history,