meshtastic: add stack to repo, gitignore tiles, add scripts dir placeholder

This commit is contained in:
Paccoco
2026-05-05 12:15:05 -05:00
parent 462edb32b1
commit 0c87184ec4
6 changed files with 1824 additions and 0 deletions

487
meshtastic/scripts/mm_wx.py Executable file
View File

@@ -0,0 +1,487 @@
#!/usr/bin/env python3
# mm_meta:
# name: WX (Weather Alerts)
# emoji: ⛈️
# language: Python
"""
WX (Weather Alerts) — MeshMonitor Script
Modes:
A) Timer Trigger / Timed Event (automatic alerts)
- If MESSAGE env var is not set, assume TIMER mode.
- Fetch NWS alerts for WX_LAT/WX_LON
- Dedupes using WX_STATE_PATH
- Emits NEW / UPDATED / CLEARED bulletins only when something changes
B) Auto Responder (on-demand)
- !wx
- !wx detail N
- !wx help
Deployment note:
- Uses Python standard library only (no pip deps). Works in locked-down containers (PEP 668 safe).
v1.0.3 changes:
- Added MeshMonitor script metadata (mm_meta) for UI display + timer name autofill
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
# =========================
# VERSION
# =========================
WX_VERSION = "v1.0.3"
# =========================
# KISS CONFIG (EDIT THESE)
# =========================
WX_LAT = 25.7617
WX_LON = -80.1918
# Allow severities. Set to empty set() to allow all.
WX_SEVERITIES_ALLOW = {"Extreme", "Severe", "Moderate"}
# Optional noise suppression. Set to empty set() to block nothing.
WX_EVENTS_BLOCK = {"Special Weather Statement"}
# NWS prefers a real UA with contact info
WX_USER_AGENT = "WX-MeshMonitor-Script (contact: you@example.com)"
# Timer behavior
WX_STATE_PATH = "/data/wx_state.json"
WX_TIMER_SILENT_NOCHANGE = True # recommended
# If True: timer runs will NOT emit error messages on fetch failure (prevents mesh noise on transient outages)
TIMER_SILENT_ON_FETCH_ERROR = True
# LoRa-friendly chunking
MAX_LINE_LEN = 180
# Documentation only (does not control scheduling)
WX_TIMER_SCHEDULE_NOTE = "*/5 * * * *"
# Network timeout seconds
HTTP_TIMEOUT = 12
# Retry/backoff for transient failures (DNS "Try again", timeouts, intermittent connectivity)
HTTP_RETRIES = 3
HTTP_RETRY_SLEEP_SECONDS = 2
# =========================
# Data model
# =========================
@dataclass
class Alert:
nid: str # NWS feature id (URL string)
event: str
severity: str
urgency: str
certainty: str
headline: str
area_desc: str
description: str
instruction: str
sent: str
effective: str
ends: str
expires: str
ugc: List[str]
same: List[str]
@staticmethod
def from_feature(f: Dict[str, Any]) -> "Alert":
nid = str(f.get("id") or "")
p = (f.get("properties") or {})
geo = (p.get("geocode") or {})
ugc = geo.get("UGC") or []
same = geo.get("SAME") or []
return Alert(
nid=nid,
event=p.get("event") or "Unknown",
severity=p.get("severity") or "Unknown",
urgency=p.get("urgency") or "Unknown",
certainty=p.get("certainty") or "Unknown",
headline=p.get("headline") or "",
area_desc=p.get("areaDesc") or "",
description=p.get("description") or "",
instruction=p.get("instruction") or "",
sent=p.get("sent") or "",
effective=p.get("effective") or "",
ends=p.get("ends") or "",
expires=p.get("expires") or "",
ugc=[str(x) for x in ugc if x],
same=[str(x) for x in same if x],
)
def ends_best(self) -> str:
return self.ends or self.expires or self.sent or ""
def fingerprint(self) -> str:
"""
Stable-ish fingerprint for dedupe:
- feature id + timestamps that commonly change on updates
"""
base = "|".join([self.nid, self.sent, self.effective, self.ends, self.expires])
return hashlib.sha256(base.encode("utf-8")).hexdigest()[:24]
def summary_line(self) -> str:
end = self.ends_best().replace("T", " ").replace("Z", "Z")
return f"{self.event} [{self.severity}] (ends {end})"
# =========================
# MeshMonitor output helpers
# =========================
def emit_response(text: str) -> None:
print(json.dumps({"response": text}, ensure_ascii=False))
def emit_responses(lines: List[str]) -> None:
print(json.dumps({"responses": lines}, ensure_ascii=False))
def chunk_text(text: str, max_len: int = MAX_LINE_LEN) -> List[str]:
out: List[str] = []
cur = ""
def flush() -> None:
nonlocal cur
if cur.strip():
out.append(cur.rstrip())
cur = ""
for line in text.splitlines():
line = line.rstrip()
if not line:
if len(cur) + 1 > max_len:
flush()
cur += "\n"
continue
if len(cur) + len(line) + 1 > max_len:
flush()
while len(line) > max_len:
out.append(line[:max_len])
line = line[max_len:]
cur += line + "\n"
flush()
return [x for x in out if x.strip()]
# =========================
# NWS fetch + filters (stdlib only)
# =========================
def http_get_json(url: str) -> Dict[str, Any]:
last_err: Optional[Exception] = None
for attempt in range(1, HTTP_RETRIES + 1):
req = Request(
url,
headers={
"User-Agent": WX_USER_AGENT,
"Accept": "application/geo+json",
},
method="GET",
)
try:
with urlopen(req, timeout=HTTP_TIMEOUT) as resp:
data = resp.read().decode("utf-8", errors="replace")
return json.loads(data)
except HTTPError as e:
# HTTP errors are typically not transient; surface immediately.
raise RuntimeError(f"HTTP {e.code} {e.reason}") from e
except URLError as e:
last_err = e
# Common transient: EAI_AGAIN -> "[Errno -3] Try again"
if attempt < HTTP_RETRIES:
time.sleep(HTTP_RETRY_SLEEP_SECONDS)
continue
raise RuntimeError(f"URL error: {e.reason}") from e
except Exception as e:
last_err = e
if attempt < HTTP_RETRIES:
time.sleep(HTTP_RETRY_SLEEP_SECONDS)
continue
raise RuntimeError(str(e)) from e
raise RuntimeError(str(last_err) if last_err else "Unknown error")
def fetch_alerts(lat: float, lon: float) -> List[Alert]:
url = f"https://api.weather.gov/alerts/active?point={lat},{lon}"
j = http_get_json(url)
feats = j.get("features") or []
alerts = [Alert.from_feature(f) for f in feats]
out: List[Alert] = []
for a in alerts:
if WX_SEVERITIES_ALLOW and a.severity not in WX_SEVERITIES_ALLOW:
continue
if WX_EVENTS_BLOCK and a.event in WX_EVENTS_BLOCK:
continue
out.append(a)
out.sort(key=lambda x: (x.severity, x.event, x.ends_best(), x.nid))
return out
# =========================
# State (dedupe) for TIMER mode
# =========================
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
def load_state(path: str) -> Dict[str, Any]:
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f) or {}
except Exception:
return {}
def save_state(path: str, state: Dict[str, Any]) -> None:
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
os.replace(tmp, path)
# =========================
# Formatting
# =========================
def format_help() -> str:
return "\n".join([
f"WX HELP ({WX_VERSION})",
"!wx -> summary of active alerts",
"!wx detail <N> -> detailed bulletin for alert N",
"!wx help -> this help",
"",
f"Timer schedule note: {WX_TIMER_SCHEDULE_NOTE}",
]).strip()
def format_summary(alerts: List[Alert]) -> str:
if not alerts:
return "WX SUMMARY\nNo active alerts for this area."
lines = ["WX SUMMARY"]
for i, a in enumerate(alerts, start=1):
lines.append(f"{i}) {a.summary_line()}")
lines.append("")
lines.append("Use: !wx detail <N>")
return "\n".join(lines).strip()
def format_detail(alerts: List[Alert], n: int) -> str:
if not alerts:
return "WX\nNo active alerts for this area."
if n < 1 or n > len(alerts):
return f"WX\nOut of range. Choose 1..{len(alerts)}"
a = alerts[n - 1]
parts: List[str] = []
parts.append(f"WX ALERT ({a.severity})")
parts.append(a.event)
if a.ugc:
parts.append(f"UGC: {'-'.join(a.ugc)}")
if a.same:
parts.append(f"SAME: {','.join(a.same)}")
end = a.ends_best()
if end:
parts.append(f"Ends: {end}")
if a.area_desc:
parts.append("")
parts.append(a.area_desc)
if a.instruction:
parts.append(a.instruction.strip())
elif a.headline:
parts.append(a.headline.strip())
elif a.description:
parts.append(a.description.strip())
return "\n".join(parts).strip()
def format_timer_bulletin(title: str, alerts: List[Alert]) -> str:
lines: List[str] = [title]
for a in alerts:
lines.append(f"- {a.summary_line()}")
return "\n".join(lines).strip()
# =========================
# Commands (Auto Responder mode)
# =========================
def parse_command(message: str) -> Tuple[str, Optional[int]]:
msg = message.strip()
if re.fullmatch(r"!wx", msg, flags=re.IGNORECASE):
return ("summary", None)
m = re.fullmatch(r"!wx\s+detail\s+([0-9]+)", msg, flags=re.IGNORECASE)
if m:
return ("detail", int(m.group(1)))
if re.fullmatch(r"!wx\s+help", msg, flags=re.IGNORECASE):
return ("help", None)
return ("unknown", None)
# =========================
# TIMER mode
# =========================
def run_timer_mode(alerts: List[Alert]) -> None:
state = load_state(WX_STATE_PATH)
prev_by_id: Dict[str, str] = dict(state.get("active_by_id") or {})
prev_ids = set(prev_by_id.keys())
cur_by_id: Dict[str, str] = {a.nid: a.fingerprint() for a in alerts}
cur_ids = set(cur_by_id.keys())
new_ids = sorted(cur_ids - prev_ids)
cleared_ids = sorted(prev_ids - cur_ids)
updated_ids: List[str] = []
for nid in sorted(cur_ids & prev_ids):
if prev_by_id.get(nid) != cur_by_id.get(nid):
updated_ids.append(nid)
responses: List[str] = []
if new_ids:
new_alerts = [a for a in alerts if a.nid in set(new_ids)]
responses.append(format_timer_bulletin("WX NEW ALERT(S)", new_alerts))
if updated_ids:
upd_alerts = [a for a in alerts if a.nid in set(updated_ids)]
responses.append(format_timer_bulletin("WX UPDATED ALERT(S)", upd_alerts))
if cleared_ids:
cleared_lines = ["WX CLEARED ALERT(S)"]
prev_meta: Dict[str, Any] = dict(state.get("meta_by_id") or {})
for nid in cleared_ids:
meta = prev_meta.get(nid) or {}
ev = meta.get("event") or "Alert"
sev = meta.get("severity") or ""
cleared_lines.append(f"- {ev}{' [' + sev + ']' if sev else ''}")
responses.append("\n".join(cleared_lines).strip())
meta_by_id: Dict[str, Any] = {}
for a in alerts:
meta_by_id[a.nid] = {"event": a.event, "severity": a.severity}
save_state(WX_STATE_PATH, {
"last_run_utc": utc_now_iso(),
"active_by_id": cur_by_id,
"meta_by_id": meta_by_id,
})
if not responses:
if WX_TIMER_SILENT_NOCHANGE:
emit_response("")
return
emit_response("WX: no changes.")
return
final_msgs: List[str] = []
for b in responses:
final_msgs.extend(chunk_text(b, MAX_LINE_LEN))
if len(final_msgs) == 1:
emit_response(final_msgs[0])
else:
emit_responses(final_msgs)
# =========================
# Main
# =========================
def main() -> int:
msg = (os.environ.get("MESSAGE") or "").strip()
is_timer = not bool(msg)
try:
alerts = fetch_alerts(WX_LAT, WX_LON)
except Exception as e:
# Timer mode: optionally stay silent to avoid spamming the mesh on transient outages
if is_timer and TIMER_SILENT_ON_FETCH_ERROR:
emit_response("")
return 0
# On-demand: show error to requesting user
emit_response(f"WX error: failed to fetch alerts ({e})")
return 0
# TIMER mode: no inbound message context
if is_timer:
run_timer_mode(alerts)
return 0
cmd, n = parse_command(msg)
if cmd == "help":
emit_response(format_help())
return 0
if cmd == "unknown":
emit_response("WX: Unknown command. Try: !wx help")
return 0
if cmd == "summary":
emit_response(format_summary(alerts))
return 0
if cmd == "detail" and n is not None:
detail = format_detail(alerts, n)
chunks = chunk_text(detail, MAX_LINE_LEN)
if len(chunks) == 1:
emit_response(chunks[0])
else:
emit_responses(chunks)
return 0
emit_response("WX: Unknown state. Try: !wx help")
return 0
if __name__ == "__main__":
raise SystemExit(main())