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

View File

View File

@@ -0,0 +1,673 @@
#!/usr/bin/env python3
"""
PirateWeatherADV — Weather script for mesh network auto-responders.
Uses the Pirate Weather API for data and OpenStreetMap Nominatim for geocoding.
TWO SEPARATE TRIGGERS:
weather / w — Current conditions only (single message)
Output example:
Weather for Peterborough, Ontario, Canada: Misty.
Currently 40°F / 4°C (feels like 32°F / 0°C).
Today: High 47°F / 8°C, Low 38°F / 3°C.
Humidity: 96%, Wind: 9 mph.
forecast / f — 7-day forecast (split into as many messages as needed)
Output example (message 1 of 2):
7-Day Forecast - Peterborough, Ontario, Canada:
Mon: Misty. Hi 40°F/4°C Lo 33°F/1°C 💧85%
Tue: Cloudy. Hi 47°F/8°C Lo 38°F/3°C 💧40%
Wed: Partly Cloudy. Hi 52°F/11°C Lo 41°F/5°C 💧15%
Output example (message 2 of 2):
Thu: Clear. Hi 55°F/13°C Lo 43°F/6°C
Fri: Rain. Hi 48°F/9°C Lo 39°F/4°C 💧90%
Sat: Cloudy. Hi 44°F/7°C Lo 36°F/2°C 💧50%
Sun: Clear. Hi 50°F/10°C Lo 38°F/3°C 💧10%
GPS MODE (no location typed):
Both triggers fall back to GPS coordinates in this order:
1. FROM_LAT / FROM_LON — GPS sent by the requesting node
2. LOCAL_LAT / LOCAL_LON — local node GPS (also accepts MM_LAT / MM_LON)
GPS mode output uses emoji format with a reverse-geocoded city name.
GPS mode example output:
📍 Peterborough, CA
🌡️ 40°F / 4°C (feels like 32°F / 0°C)
📊 Forecast: Misty
↕️ High: 47°F / 8°C Low: 38°F / 3°C
💧 Humidity: 96% 💨 Wind: 9 mph
INPUT FORMATS (spaces after commas are fine, all produce identical results):
weather peterborough,ontario,canada
weather peterborough, ontario, canada
w peterborough, ontario, canada
forecast peterborough, ontario, canada
f peterborough, ontario, canada
MODE DETECTION ORDER (how the script decides weather vs forecast):
1. TRIGGER env var — MeshMonitor sets this to the matched trigger pattern.
Most reliable source; checked first.
2. MESSAGE env var — Full original message typed by the user. Fallback if
TRIGGER alone doesn't identify the mode.
3. PARAM_mode — Explicit override env var ('forecast' or 'weather').
Useful for Timed Events.
4. Leading keyword — Keyword at the start of CLI arg or PARAM_location
(e.g. "forecast peterborough,ontario,canada").
5. Default — 'weather' if none of the above match.
All weather data is sourced exclusively from Pirate Weather (pirateweather.net).
Location names are resolved via OpenStreetMap Nominatim (free, no API key needed).
Requirements:
- Python 3.6+
- PIRATE_WEATHER_API_KEY environment variable (get free key at https://pirateweather.net/)
Setup:
1. Get a free API key from https://pirateweather.net/
2. Add to docker-compose.yaml environment variables:
- PIRATE_WEATHER_API_KEY=your_api_key_here
- LOCAL_LAT=your_latitude # optional GPS fallback for local node
- LOCAL_LON=your_longitude # (MM_LAT / MM_LON also accepted as aliases)
3. Ensure volume mapping in docker-compose.yaml:
- ./scripts:/data/scripts
4. Copy PirateWeatherADV.py to scripts/ directory
5. Make executable: chmod +x scripts/PirateWeatherADV.py
MeshMonitor Auto Responder Configuration:
Navigate to Settings → Automation → Auto Responder → Add Trigger.
Create TWO trigger entries, each pointing to the same script:
Trigger 1 — Current conditions (weather / w):
Trigger field: weather, weather {location:.+}, w {location:.+}
Response type: Script
Script path: /data/scripts/PirateWeatherADV.py
Trigger 2 — 7-day forecast (forecast / f):
Trigger field: forecast, forecast {location:.+}, f {location:.+}
Response type: Script
Script path: /data/scripts/PirateWeatherADV.py
The comma-separated patterns in each trigger field allow one entry to handle
both the bare keyword (GPS mode) and the keyword + location variants.
MeshMonitor sets the TRIGGER env var to the matched pattern, which the script
uses to reliably determine weather vs forecast mode regardless of whether
PARAM_location contains the keyword.
Local testing:
Current conditions by location:
TEST_MODE=true PARAM_location="peterborough,ontario,canada" PIRATE_WEATHER_API_KEY=your_key python3 PirateWeatherADV.py
7-day forecast by location:
TEST_MODE=true PARAM_location="forecast peterborough,ontario,canada" PIRATE_WEATHER_API_KEY=your_key python3 PirateWeatherADV.py
GPS mode — current conditions:
TEST_MODE=true FROM_LAT=43.55 FROM_LON=-78.49 PIRATE_WEATHER_API_KEY=your_key python3 PirateWeatherADV.py
GPS mode — forecast (simulate forecast trigger):
TEST_MODE=true TRIGGER=forecast FROM_LAT=43.55 FROM_LON=-78.49 PIRATE_WEATHER_API_KEY=your_key python3 PirateWeatherADV.py
CLI argument — current conditions (Timed Events):
TEST_MODE=true PIRATE_WEATHER_API_KEY=your_key python3 PirateWeatherADV.py peterborough,ontario,canada
CLI argument — forecast (Timed Events):
TEST_MODE=true PIRATE_WEATHER_API_KEY=your_key python3 PirateWeatherADV.py forecast peterborough,ontario,canada
"""
import os
import sys
import json
import time
import urllib.request
import urllib.parse
from typing import Optional, Tuple, Dict, List, Any
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
PIRATE_WEATHER_API_KEY = os.environ.get('PIRATE_WEATHER_API_KEY', '')
TEST_MODE = os.environ.get('TEST_MODE', 'false').lower() == 'true'
# MeshMonitor enforces a 200-char max per message and a 10-second script timeout.
# Network timeouts: geocode 3s + API 4s + reverse geocode 3s = 10s worst case.
MSG_LIMIT = 200
# Day-of-week abbreviations indexed by Python's tm_wday (0=Mon … 6=Sun)
DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
# Trigger keywords that identify forecast mode
FORECAST_KEYWORDS = ('forecast', 'f')
# Trigger keywords that identify weather (current conditions) mode
WEATHER_KEYWORDS = ('weather', 'w')
# ---------------------------------------------------------------------------
# Unit conversion
# ---------------------------------------------------------------------------
def to_c(f: float) -> float:
"""Convert Fahrenheit to Celsius."""
return (f - 32) * 5 / 9
def fmt_temp(f: float) -> str:
"""Format a temperature as XX°F / XX°C."""
return f'{f:.0f}°F / {to_c(f):.0f}°C'
def fmt_temp_compact(f: float) -> str:
"""Compact temperature format for forecast lines: XX°F/XX°C."""
return f'{f:.0f}°F/{to_c(f):.0f}°C'
# ---------------------------------------------------------------------------
# Coordinate helpers
# ---------------------------------------------------------------------------
def _parse_coords(lat_str: str, lon_str: str, label: str) -> Optional[Tuple[float, float]]:
"""Parse and range-validate a lat/lon pair. Returns (lat, lon) or None."""
try:
lat = float(lat_str)
lon = float(lon_str)
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
print(f'Coordinates out of valid range for {label}: {lat}, {lon}', file=sys.stderr)
return None
return (lat, lon)
except ValueError:
print(f'Invalid coordinate values for {label}: {lat_str}, {lon_str}', file=sys.stderr)
return None
def geocode_location(location: str) -> Optional[Tuple[float, float]]:
"""
Forward-geocode a location string to (lat, lon) using Nominatim.
Returns None if the location cannot be found or the request fails.
"""
try:
params = {'q': location, 'format': 'json', 'limit': 1}
url = 'https://nominatim.openstreetmap.org/search?' + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={'User-Agent': 'PirateWeatherADV/1.0'})
with urllib.request.urlopen(req, timeout=3) as resp:
data = json.loads(resp.read().decode('utf-8'))
if data:
return (float(data[0]['lat']), float(data[0]['lon']))
except Exception as e:
print(f'Geocoding error for "{location}": {e}', file=sys.stderr)
return None
def reverse_geocode_city(lat: float, lon: float) -> str:
"""
Reverse-geocode (lat, lon) to a city name using Nominatim.
Returns a raw coordinate string if the lookup fails.
"""
try:
params = {'lat': lat, 'lon': lon, 'format': 'json', 'zoom': 10}
url = 'https://nominatim.openstreetmap.org/reverse?' + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={'User-Agent': 'PirateWeatherADV/1.0'})
with urllib.request.urlopen(req, timeout=3) as resp:
data = json.loads(resp.read().decode('utf-8'))
address = data.get('address') or {}
city = (
address.get('city')
or address.get('town')
or address.get('village')
or address.get('hamlet')
or address.get('county')
)
state = address.get('state', '')
country_code = address.get('country_code', '').upper()
if city:
if state and country_code == 'US':
return f'{city}, {state}'
elif country_code:
return f'{city}, {country_code}'
else:
return city
except Exception as e:
print(f'Reverse geocode error: {e}', file=sys.stderr)
return f'{lat:.4f}, {lon:.4f}'
# ---------------------------------------------------------------------------
# Label formatter
# ---------------------------------------------------------------------------
def _fmt_label(location: str) -> str:
"""
Build a clean display label from a raw location string.
Splits on commas, capitalises each part carefully so:
- Abbreviations (ON, NY, CA) are preserved in upper case
- Apostrophes (St. John's) are not mangled by str.title()
- Empty parts from double/trailing commas are skipped
"""
def _fmt_part(part: str) -> str:
words = part.strip().split()
out = []
for w in words:
if not w:
continue
if w.isupper() and len(w) <= 3:
out.append(w)
else:
out.append(w[0].upper() + w[1:])
return ' '.join(out)
parts = [p.strip() for p in location.split(',') if p.strip()]
return ', '.join(_fmt_part(p) for p in parts)
# ---------------------------------------------------------------------------
# Input resolution
# ---------------------------------------------------------------------------
def resolve_input() -> Tuple[Optional[Tuple[float, float]], Optional[str], str, str]:
"""
Determine coordinates, display label, output mode (weather/forecast),
and input mode (location/gps).
Returns:
(coords, label, output_mode, input_mode)
coords — (lat, lon) or None if nothing could be resolved
label — display name string, or None if GPS mode
output_mode — 'weather' (current conditions) or 'forecast' (7-day)
input_mode — 'location' or 'gps'
Resolution order for location:
1. CLI arguments (e.g. Timed Events)
2. PARAM_location env var (trigger pipeline)
Resolution order for coordinates when no location string given:
3. FROM_LAT / FROM_LON — requesting node GPS
4. LOCAL_LAT / LOCAL_LON or MM_LAT / MM_LON — local node GPS
Output mode is determined by:
- The leading keyword in the location string (forecast/f vs weather/w)
- PARAM_mode env var as a fallback override ('forecast' or 'f')
- Defaults to 'weather' if no keyword is found
"""
# ---------------------------------------------------------------------------
# Determine output_mode from multiple sources, in priority order:
#
# 1. TRIGGER env var — MeshMonitor sets this to the pattern that matched
# e.g. "forecast {location:.+}" or "forecast" — most reliable source.
# 2. MESSAGE env var — full original message, e.g. "forecast peterborough..."
# Used as fallback when TRIGGER doesn't contain the keyword.
# 3. PARAM_mode env var — explicit override, useful for Timed Events.
# 4. Leading keyword in the CLI arg or PARAM_location string.
# 5. Default: 'weather'
# ---------------------------------------------------------------------------
output_mode = 'weather' # default
# 1. Check TRIGGER env var (most reliable — set by MeshMonitor)
trigger = os.environ.get('TRIGGER', '').strip().lower()
if any(trigger == kw or trigger.startswith(kw + ' ') or trigger.startswith(kw + ',')
for kw in FORECAST_KEYWORDS):
output_mode = 'forecast'
elif any(trigger == kw or trigger.startswith(kw + ' ') or trigger.startswith(kw + ',')
for kw in WEATHER_KEYWORDS):
output_mode = 'weather'
# 2. Check full MESSAGE env var as fallback
if output_mode == 'weather': # only if not already determined
message = os.environ.get('MESSAGE', '').strip().lower()
if any(message == kw or message.startswith(kw + ' ') or message.startswith(kw + ',')
for kw in FORECAST_KEYWORDS):
output_mode = 'forecast'
# 3. PARAM_mode explicit override
param_mode = os.environ.get('PARAM_mode', '').strip().lower()
if param_mode in ('forecast', 'f'):
output_mode = 'forecast'
elif param_mode in ('weather', 'w'):
output_mode = 'weather'
# Gather raw location string: CLI args first, then PARAM_location
location = ''
if len(sys.argv) > 1:
location = ' '.join(sys.argv[1:]).strip()
if not location:
location = os.environ.get('PARAM_location', '').strip()
# 4. Detect and strip leading keyword from the location string itself
# (handles CLI args and direct PARAM_location with keyword prefix)
loc_lower = location.lower()
stripped = False
for kw in FORECAST_KEYWORDS:
if loc_lower == kw or loc_lower.startswith(kw + ' ') or loc_lower.startswith(kw + ','):
output_mode = 'forecast'
location = location[len(kw):].strip()
stripped = True
break
if not stripped:
for kw in WEATHER_KEYWORDS:
if loc_lower == kw or loc_lower.startswith(kw + ' ') or loc_lower.startswith(kw + ','):
output_mode = 'weather'
location = location[len(kw):].strip()
break
# If we have a location string, geocode it
if location and location.lower() not in ('help', 'h', '?'):
# Normalise: strip spaces around commas, filter empty parts
parts = [p.strip() for p in location.split(',') if p.strip()]
location_normalised = ','.join(parts)
coords = geocode_location(location_normalised)
label = _fmt_label(location)
if coords:
return (coords, label, output_mode, 'location')
else:
return (None, label, output_mode, 'location')
# No location string — fall back to GPS coordinates
from_lat = os.environ.get('FROM_LAT', '').strip()
from_lon = os.environ.get('FROM_LON', '').strip()
if from_lat and from_lon:
coords = _parse_coords(from_lat, from_lon, 'requesting node')
if coords:
return (coords, None, output_mode, 'gps')
local_lat = (os.environ.get('LOCAL_LAT', '') or os.environ.get('MM_LAT', '')).strip()
local_lon = (os.environ.get('LOCAL_LON', '') or os.environ.get('MM_LON', '')).strip()
if local_lat and local_lon:
coords = _parse_coords(local_lat, local_lon, 'local node')
if coords:
return (coords, None, output_mode, 'gps')
return (None, None, output_mode, 'gps')
# ---------------------------------------------------------------------------
# Weather fetch
# ---------------------------------------------------------------------------
def get_weather(lat: float, lon: float) -> Dict[str, Any]:
"""
Fetch current conditions and 7-day daily forecast from Pirate Weather.
Returns {'data': {...}} on success or {'error': '...'} on failure.
"""
if not PIRATE_WEATHER_API_KEY:
return {'error': 'PIRATE_WEATHER_API_KEY not set. Get a free key at pirateweather.net.'}
try:
url = f'https://api.pirateweather.net/forecast/{PIRATE_WEATHER_API_KEY}/{lat},{lon}'
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=4) as resp:
data = json.loads(resp.read().decode('utf-8'))
current = data.get('currently') or {}
daily_periods = (data.get('daily') or {}).get('data') or []
today = daily_periods[0] if daily_periods else {}
temp = current.get('temperature')
feels_like = current.get('apparentTemperature')
summary = current.get('summary') or 'N/A'
humidity = current.get('humidity') or 0
wind_speed = current.get('windSpeed') or 0
high_temp = today.get('temperatureHigh')
low_temp = today.get('temperatureLow')
if temp is None or feels_like is None:
return {'error': 'Pirate Weather returned incomplete data (missing temperature).'}
# Timezone offset in seconds (offset field is a float, e.g. -5.0 or 5.5)
tz_offset_sec = int((data.get('offset') or 0) * 3600)
# Build 7-day forecast list from all available daily periods.
# Use 8 entries so we always have a full 7-day lookahead regardless
# of what day of the week today is (index 0 = today, so 8 covers today
# plus 7 future days, and build_forecast_messages caps output at 7 days).
forecast = []
for day in daily_periods[:8]:
day_time = day.get('time')
day_high = day.get('temperatureHigh')
day_low = day.get('temperatureLow')
day_sum = day.get('summary') or 'N/A'
day_precip = day.get('precipProbability') or 0
if day_time is None or day_high is None or day_low is None:
continue
forecast.append({
'time': day_time,
'summary': day_sum,
'high': day_high,
'low': day_low,
'precip_prob': day_precip,
})
return {'data': {
'temp': temp,
'feels_like': feels_like,
'summary': summary,
'humidity': humidity,
'wind_speed': wind_speed,
'high_temp': high_temp,
'low_temp': low_temp,
'forecast': forecast,
'tz_offset_sec': tz_offset_sec,
}}
except urllib.error.HTTPError as e:
return {'error': f'Weather API error: {e.code} {e.reason}'}
except urllib.error.URLError as e:
return {'error': f'Network error: {e.reason}'}
except json.JSONDecodeError:
return {'error': 'Invalid response from Pirate Weather API'}
except Exception as e:
# Sanitise exception message — strip API key if it appears
err_msg = str(e).replace(PIRATE_WEATHER_API_KEY, '***') if PIRATE_WEATHER_API_KEY else str(e)
return {'error': f'Unexpected error: {err_msg}'}
# ---------------------------------------------------------------------------
# Response formatters
# ---------------------------------------------------------------------------
def format_weather(label: str, d: dict) -> str:
"""
Format current conditions as a single message string.
Example:
Weather for Peterborough, Ontario, Canada: Misty.
Currently 40°F / 4°C (feels like 32°F / 0°C).
Today: High 47°F / 8°C, Low 38°F / 3°C.
Humidity: 96%, Wind: 9 mph.
"""
msg = (
f'Weather for {label}: {d["summary"]}. '
f'Currently {fmt_temp(d["temp"])} (feels like {fmt_temp(d["feels_like"])}). '
)
if d['high_temp'] is not None and d['low_temp'] is not None:
msg += f'Today: High {fmt_temp(d["high_temp"])}, Low {fmt_temp(d["low_temp"])}. '
msg += f'Humidity: {d["humidity"]*100:.0f}%, Wind: {d["wind_speed"]:.0f} mph.'
return msg
def format_weather_gps(lat: float, lon: float, d: dict) -> str:
"""
Format current conditions in emoji style for GPS mode.
Example:
📍 Peterborough, CA
🌡️ 40°F / 4°C (feels like 32°F / 0°C)
📊 Forecast: Misty
↕️ High: 47°F / 8°C Low: 38°F / 3°C
💧 Humidity: 96% 💨 Wind: 9 mph
"""
city = reverse_geocode_city(lat, lon)
lines = [
f'📍 {city}',
f'🌡️ {fmt_temp(d["temp"])} (feels like {fmt_temp(d["feels_like"])})',
f'📊 Forecast: {d["summary"]}',
]
if d['high_temp'] is not None and d['low_temp'] is not None:
lines.append(f'↕️ High: {fmt_temp(d["high_temp"])} Low: {fmt_temp(d["low_temp"])}')
lines.append(f'💧 Humidity: {d["humidity"]*100:.0f}% 💨 Wind: {d["wind_speed"]:.0f} mph')
return '\n'.join(lines)
def build_forecast_messages(label: str, d: dict) -> List[str]:
"""
Build the 7-day forecast as a list of messages, each within MSG_LIMIT bytes.
The first message starts with a header line. Subsequent day lines are packed
into messages greedily. If a day line starts a new message, that message
begins directly with the day line (no repeated header).
Returns a list of message strings (may be empty if no forecast data).
"""
forecast = d.get('forecast', [])
if not forecast:
return []
tz_offset = d.get('tz_offset_sec', 0)
header = f'7-Day Forecast - {label}:'
# Build all day lines first — cap at 7 days regardless of how many
# entries were returned (we fetch 8 to guarantee 7 future days exist)
day_lines = []
for day in forecast[:7]:
try:
local_ts = day['time'] + tz_offset
day_name = DAY_NAMES[time.gmtime(local_ts).tm_wday]
except Exception:
day_name = '???'
high = day['high']
low = day['low']
summ = day['summary']
precip = day['precip_prob']
hi_str = fmt_temp_compact(high)
lo_str = fmt_temp_compact(low)
precip_str = f' 💧{precip*100:.0f}%' if precip else ''
line = f'{day_name}: {summ}. Hi {hi_str} Lo {lo_str}{precip_str}'
# Safety: if a single line exceeds MSG_LIMIT (e.g. very long API summary),
# truncate it so MeshMonitor never silently drops characters.
while len(line.encode('utf-8')) > MSG_LIMIT:
line = line[:-1]
day_lines.append(line)
if not day_lines:
return []
# Pack day lines into messages greedily, respecting MSG_LIMIT bytes
messages = []
current = header # first message always starts with the header
for line in day_lines:
candidate = current + '\n' + line if current else line
if len(candidate.encode('utf-8')) <= MSG_LIMIT:
current = candidate
else:
# Current message is full — save it and start a new one
if current:
messages.append(current)
# New message starts with just the day line (no repeated header)
current = line
# Don't forget the last message
if current:
messages.append(current)
return messages
# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------
def emit(messages: List[str]) -> None:
"""
Print the correct MeshMonitor JSON output format and flush stdout.
Single message → {"response": "..."}
Multiple messages → {"responses": ["msg1", "msg2", ...]}
"""
if not messages:
print(json.dumps({'response': 'No data to display.'}))
elif len(messages) == 1:
print(json.dumps({'response': messages[0]}))
else:
print(json.dumps({'responses': messages}))
sys.stdout.flush()
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main():
try:
coords, label, output_mode, input_mode = resolve_input()
if TEST_MODE:
trigger = os.environ.get('TRIGGER', '(none)')
print(
f'TRIGGER={trigger!r} | output_mode={output_mode} | '
f'input_mode={input_mode} | coords={coords} | label={label}',
file=sys.stderr
)
# Handle no-location / no-GPS case
if coords is None:
if input_mode == 'location' and label:
emit([f'Could not find location: "{label}". '
'Try e.g. "weather peterborough,ontario,canada".'])
else:
emit([
'No location or GPS data available. '
'Options: (1) Send "weather city,province,country". '
'(2) Use CLI arg in Timed Event: "peterborough,ontario,canada". '
'(3) Ensure node has GPS (FROM_LAT/FROM_LON). '
'(4) Set LOCAL_LAT/LOCAL_LON in docker-compose.'
])
return
lat, lon = coords
result = get_weather(lat, lon)
if 'error' in result:
emit([f'Error: {result["error"]}'])
return
d = result['data']
if output_mode == 'forecast':
# Use typed label or reverse-geocoded city name for GPS mode
fc_label = label if label else reverse_geocode_city(lat, lon)
messages = build_forecast_messages(fc_label, d)
if not messages:
emit(['No forecast data available.'])
else:
emit(messages)
if TEST_MODE:
for i, msg in enumerate(messages, 1):
print(f'\n--- FORECAST MSG {i} ---\n{msg}', file=sys.stderr)
print('--- END TEST ---\n', file=sys.stderr)
else: # output_mode == 'weather'
if input_mode == 'location':
msg = format_weather(label, d)
else:
msg = format_weather_gps(lat, lon, d)
emit([msg])
if TEST_MODE:
print(f'\n--- WEATHER MSG ---\n{msg}\n--- END TEST ---\n', file=sys.stderr)
except Exception as e:
emit([f'Error: {str(e)}'])
if TEST_MODE:
import traceback
traceback.print_exc(file=sys.stderr)
sys.exit(0)
if __name__ == '__main__':
main()

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())

View File

@@ -0,0 +1,570 @@
#!/bin/sh
# MeshMonitor Upgrade Watchdog
# Monitors for upgrade trigger file and performs Docker container upgrade
set -e
# Configuration
TRIGGER_FILE="${TRIGGER_FILE:-/data/.upgrade-trigger}"
STATUS_FILE="${STATUS_FILE:-/data/.upgrade-status}"
BACKUP_DIR="${BACKUP_DIR:-/data/backups}"
CHECK_INTERVAL="${CHECK_INTERVAL:-5}"
CONTAINER_NAME="${CONTAINER_NAME:-meshmonitor}"
IMAGE_NAME="${IMAGE_NAME:-ghcr.io/yeraze/meshmonitor}"
COMPOSE_PROJECT_DIR="${COMPOSE_PROJECT_DIR:-/compose}"
DOCKER_SOCKET_TEST_REQUEST="${DOCKER_SOCKET_TEST_REQUEST:-/data/.docker-socket-test-request}"
DOCKER_SOCKET_TEST_SCRIPT="${DOCKER_SOCKET_TEST_SCRIPT:-/data/.meshmonitor-internal/test-docker-socket.sh}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log() {
echo "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
log_success() {
echo "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC}$1"
}
log_warn() {
echo "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ⚠️ $1"
}
log_error() {
echo "${RED}[$(date +'%Y-%m-%d %H:%M:%S')]${NC}$1"
}
# Write status to file for backend to read
write_status() {
echo "$1" > "$STATUS_FILE"
log "Status: $1"
}
# Create backup of data directory
create_backup() {
local backup_name="upgrade-backup-$(date +%Y%m%d_%H%M%S)"
local backup_path="$BACKUP_DIR/$backup_name"
log "Creating backup: $backup_path"
# Verify /data directory exists and is accessible
if [ ! -d "/data" ]; then
log_error "/data directory does not exist"
return 1
fi
# Create backup directory with error checking
if ! mkdir -p "$BACKUP_DIR" 2>/tmp/backup-error.log; then
log_error "Failed to create backup directory: $BACKUP_DIR"
if [ -f /tmp/backup-error.log ]; then
log_error "Error: $(cat /tmp/backup-error.log)"
rm -f /tmp/backup-error.log
fi
return 1
fi
# Verify backup directory was created
if [ ! -d "$BACKUP_DIR" ]; then
log_error "Backup directory does not exist after mkdir: $BACKUP_DIR"
return 1
fi
# Log directory info for debugging
log "Backup directory info: $(ls -ld "$BACKUP_DIR" 2>&1 || echo 'unable to stat')"
# Create backup (exclude backups directory itself)
# Capture stderr to a temp file for better error reporting
# Note: tar may report warnings about files disappearing (e.g., SQLite journal files)
# which can be safely ignored as long as the backup file is created successfully
tar -czf "$backup_path.tar.gz" -C /data --exclude='backups' --exclude='.upgrade-*' . 2>/tmp/tar-error.log
local tar_exit=$?
# Check if backup file was created and has content
if [ ! -f "$backup_path.tar.gz" ]; then
log_error "Backup file was not created: $backup_path.tar.gz"
# Show tar errors
if [ -f /tmp/tar-error.log ]; then
log_error "tar error output:"
while IFS= read -r line; do
log_error " $line"
done < /tmp/tar-error.log
fi
rm -f /tmp/tar-error.log
return 1
fi
local backup_size=$(stat -c%s "$backup_path.tar.gz" 2>/dev/null || echo "0")
# Check if backup is too small (likely corrupt)
if [ "$backup_size" -lt 100 ]; then
log_error "Backup file is suspiciously small (${backup_size} bytes)"
rm -f /tmp/tar-error.log
return 1
fi
# Exit code 0 = success, 1 = warnings (e.g., file changed during read)
# Both are acceptable as long as the backup file exists and has reasonable size
if [ $tar_exit -eq 0 ] || [ $tar_exit -eq 1 ]; then
if [ $tar_exit -eq 1 ] && [ -f /tmp/tar-error.log ]; then
# Log warnings but don't fail
log_warn "tar reported warnings (likely ephemeral files like SQLite journals):"
while IFS= read -r line; do
log_warn " $line"
done < /tmp/tar-error.log
fi
log_success "Backup created: $backup_path.tar.gz (${backup_size} bytes)"
rm -f /tmp/tar-error.log
echo "$backup_path.tar.gz"
return 0
else
log_error "Failed to create backup (tar exit code: $tar_exit)"
# Show the actual tar error
if [ -f /tmp/tar-error.log ]; then
log_error "tar error output:"
while IFS= read -r line; do
log_error " $line"
done < /tmp/tar-error.log
fi
rm -f /tmp/tar-error.log
return 1
fi
}
# Pull new Docker image
pull_image() {
local version="$1"
local image="${IMAGE_NAME}:${version}"
log "Pulling image: $image"
if docker pull "$image"; then
log_success "Image pulled: $image"
# Tag as latest if not already latest
if [ "$version" != "latest" ]; then
docker tag "$image" "${IMAGE_NAME}:latest"
log_success "Tagged as latest"
fi
return 0
else
log_error "Failed to pull image: $image"
return 1
fi
}
# Recreate container with new image
recreate_container() {
log "Recreating container: $CONTAINER_NAME"
# Resolve compose directory - try configured path, then known mount points
# Older sidecar configs used /data/compose, current uses /compose
# Accept both .yml and .yaml extensions (both are valid for Docker Compose)
local compose_dir=""
local compose_file=""
for try_dir in "$COMPOSE_PROJECT_DIR" "/compose" "/data/compose"; do
if [ -d "$try_dir" ]; then
if [ -f "$try_dir/docker-compose.yml" ]; then
compose_dir="$try_dir"
compose_file="docker-compose.yml"
break
elif [ -f "$try_dir/docker-compose.yaml" ]; then
compose_dir="$try_dir"
compose_file="docker-compose.yaml"
break
fi
fi
done
if [ -n "$compose_dir" ] && [ "$compose_dir" != "$COMPOSE_PROJECT_DIR" ]; then
if [ "$compose_dir" = "/data/compose" ]; then
log_warn "Falling back to legacy path /data/compose - please update COMPOSE_PROJECT_DIR to /compose"
else
log_warn "COMPOSE_PROJECT_DIR=$COMPOSE_PROJECT_DIR not found, falling back to $compose_dir"
fi
fi
if [ -z "$compose_dir" ]; then
log_error "No docker-compose.yml/yaml found at $COMPOSE_PROJECT_DIR, /compose, or /data/compose"
log_error "The upgrade sidecar requires Docker Compose files to recreate containers safely."
log_error "Mount your compose directory to /compose in the sidecar container."
return 1
fi
log "Using compose directory: $compose_dir"
# Verify docker compose is available
if ! docker compose version >/dev/null 2>&1; then
log_error "docker compose not available - required for container recreation"
return 1
fi
# Detect which compose files were originally used
local original_config_files=$(docker inspect --format='{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$CONTAINER_NAME" 2>/dev/null || echo "")
local compose_files=""
if [ -n "$original_config_files" ]; then
log "Original compose files: $original_config_files"
for config_file in $(echo "$original_config_files" | tr ',' ' '); do
local filename=$(basename "$config_file")
if [ -f "$compose_dir/$filename" ]; then
compose_files="$compose_files -f $filename"
log " Using: $filename"
else
log_warn " Not found: $filename (skipping)"
fi
done
fi
# Fallback if no compose files detected from labels
if [ -z "$compose_files" ]; then
compose_files="-f $compose_file"
# Check for upgrade overlay with either extension
if [ -f "$compose_dir/docker-compose.upgrade.yml" ]; then
compose_files="$compose_files -f docker-compose.upgrade.yml"
elif [ -f "$compose_dir/docker-compose.upgrade.yaml" ]; then
compose_files="$compose_files -f docker-compose.upgrade.yaml"
fi
log "Using default compose files: $compose_files"
fi
# Detect project name from container labels
local detected_project=$(docker inspect --format='{{index .Config.Labels "com.docker.compose.project"}}' "$CONTAINER_NAME" 2>/dev/null || echo "")
local project_flag=""
if [ -n "$detected_project" ]; then
project_flag="-p $detected_project"
log "Using project name: $detected_project"
elif [ -n "$COMPOSE_PROJECT_NAME" ]; then
project_flag="-p $COMPOSE_PROJECT_NAME"
log "Using env project name: $COMPOSE_PROJECT_NAME"
fi
# Pull latest image
log "Pulling latest image..."
if docker pull "${IMAGE_NAME}:latest" 2>/dev/null; then
log_success "Image pulled: ${IMAGE_NAME}:latest"
fi
# Recreate via docker compose (handles ports, volumes, env, networks - everything)
cd "$compose_dir" || return 1
log "Running: docker compose $project_flag $compose_files up -d --force-recreate --no-deps $CONTAINER_NAME"
if docker compose $project_flag $compose_files up -d --force-recreate --no-deps "$CONTAINER_NAME"; then
# Log the port mappings on the new container for verification
local new_ports=$(docker port "$CONTAINER_NAME" 2>/dev/null)
if [ -n "$new_ports" ]; then
log "Port mappings on recreated container:"
echo "$new_ports" | while IFS= read -r line; do log " $line"; done
fi
log_success "Container recreated successfully via Docker Compose"
return 0
else
log_error "Failed to recreate container via Docker Compose"
return 1
fi
}
# Wait for container health check
wait_for_health() {
local max_wait=120
local elapsed=0
log "Waiting for container health check..."
# Get BASE_URL from container env vars, default to empty if not set
local base_url=$(docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER_NAME" 2>/dev/null | grep '^BASE_URL=' | cut -d'=' -f2 | tr -d '\r\n')
# Construct health endpoint URL with BASE_URL if present
local health_path="/api/health"
if [ -n "$base_url" ] && [ "$base_url" != "/" ]; then
health_path="${base_url}/api/health"
fi
while [ $elapsed -lt $max_wait ]; do
# Check if container is running
if ! docker ps --filter "name=$CONTAINER_NAME" --filter "status=running" | grep -q "$CONTAINER_NAME"; then
log_warn "Container not running yet..."
sleep 5
elapsed=$((elapsed + 5))
continue
fi
# Get container IP directly from Docker inspect - more reliable than DNS after recreation
# Try multiple networks as the container might be on different networks
local container_ip=""
container_ip=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CONTAINER_NAME" 2>/dev/null | head -n1)
# If no IP yet, wait for container to get network assigned
if [ -z "$container_ip" ]; then
log_warn "Container has no IP assigned yet..."
sleep 5
elapsed=$((elapsed + 5))
continue
fi
local health_url="http://${container_ip}:3001${health_path}"
# Only log URL on first attempt or if it changed
if [ "$elapsed" -eq 0 ] || [ -z "$last_health_url" ] || [ "$health_url" != "$last_health_url" ]; then
log "Health endpoint: $health_url"
last_health_url="$health_url"
fi
# Try to check health endpoint using container IP directly
# This is more reliable than container name DNS after recreation
if wget -q -O /dev/null --timeout=5 "$health_url" 2>/dev/null || \
curl -sf "$health_url" >/dev/null 2>&1; then
log_success "Health check passed"
return 0
fi
log "Waiting for health check... (${elapsed}s/${max_wait}s)"
sleep 5
elapsed=$((elapsed + 5))
done
log_error "Health check timeout after ${max_wait}s"
return 1
}
# Clean up old Docker images to free disk space
cleanup_old_images() {
log "Cleaning up old MeshMonitor images..."
# Get the current image ID being used by the container
local current_image_id=$(docker inspect --format='{{.Image}}' "$CONTAINER_NAME" 2>/dev/null | cut -d':' -f2 | cut -c1-12)
if [ -z "$current_image_id" ]; then
log_warn "Could not determine current image ID, skipping cleanup"
return 0
fi
log "Current image ID: $current_image_id"
# Find all MeshMonitor images (excluding the current one)
local old_images=$(docker images "${IMAGE_NAME}" --format '{{.ID}} {{.Tag}}' 2>/dev/null | while read id tag; do
# Get short ID for comparison
local short_id=$(echo "$id" | cut -c1-12)
if [ "$short_id" != "$current_image_id" ]; then
echo "$id"
fi
done)
if [ -z "$old_images" ]; then
log "No old images to clean up"
return 0
fi
# Count images to be removed
local image_count=$(echo "$old_images" | wc -l)
log "Found $image_count old image(s) to remove"
# Remove each old image
local removed=0
local failed=0
for image_id in $old_images; do
if docker rmi "$image_id" 2>/dev/null; then
removed=$((removed + 1))
log "Removed image: $image_id"
else
failed=$((failed + 1))
log_warn "Could not remove image: $image_id (may be in use)"
fi
done
if [ $removed -gt 0 ]; then
log_success "Cleaned up $removed old image(s)"
fi
if [ $failed -gt 0 ]; then
log_warn "$failed image(s) could not be removed"
fi
# Also clean up dangling images (untagged images from the build process)
local dangling=$(docker images -f "dangling=true" -q 2>/dev/null)
if [ -n "$dangling" ]; then
log "Removing dangling images..."
echo "$dangling" | xargs docker rmi 2>/dev/null || true
log_success "Dangling images cleaned up"
fi
return 0
}
# Perform upgrade
perform_upgrade() {
local trigger_data
local version
local backup_enabled
local upgrade_id
local backup_path
# Read trigger file
if [ ! -f "$TRIGGER_FILE" ]; then
log_error "Trigger file not found"
return 1
fi
trigger_data=$(cat "$TRIGGER_FILE")
version=$(echo "$trigger_data" | grep -o '"version"[ ]*:[ ]*"[^"]*"' | sed 's/.*:.*"\(.*\)"/\1/')
backup_enabled=$(echo "$trigger_data" | grep -o '"backup"[ ]*:[ ]*[^,}]*' | sed 's/.*:[ ]*//')
upgrade_id=$(echo "$trigger_data" | grep -o '"upgradeId"[ ]*:[ ]*"[^"]*"' | sed 's/.*:.*"\(.*\)"/\1/')
if [ -z "$version" ]; then
version="latest"
fi
log "=================================================="
log "Starting upgrade to version: $version"
log "Upgrade ID: $upgrade_id"
log "Backup enabled: $backup_enabled"
log "=================================================="
# Remove trigger file immediately to prevent re-triggering
rm -f "$TRIGGER_FILE"
# Step 1: Create backup
if [ "$backup_enabled" != "false" ]; then
write_status "backing_up"
if backup_path=$(create_backup); then
log_success "Backup completed: $backup_path"
else
write_status "failed"
log_error "Backup failed - aborting upgrade"
return 1
fi
else
log_warn "Backup disabled - skipping"
fi
# Step 2: Pull new image
write_status "downloading"
if ! pull_image "$version"; then
write_status "failed"
log_error "Image pull failed - aborting upgrade"
return 1
fi
# Step 3: Recreate container
write_status "restarting"
if ! recreate_container; then
write_status "failed"
log_error "Container recreation failed"
# Attempt rollback if backup exists
if [ -n "$backup_path" ] && [ -f "$backup_path" ]; then
log_warn "Attempting rollback..."
write_status "rolling_back"
# Rollback logic would go here
# For now, just log the error
log_error "Manual intervention required - backup available at: $backup_path"
fi
return 1
fi
# Step 4: Health check
write_status "health_check"
if ! wait_for_health; then
write_status "failed"
log_error "Health check failed - upgrade may have issues"
return 1
fi
# Step 5: Clean up old images to free disk space
write_status "cleanup"
cleanup_old_images
# Success!
write_status "complete"
log_success "=================================================="
log_success "Upgrade completed successfully!"
log_success "Version: $version"
log_success "Upgrade ID: $upgrade_id"
log_success "=================================================="
return 0
}
# Main loop
main() {
log "=================================================="
log "MeshMonitor Upgrade Watchdog Starting"
log "=================================================="
log "Container: $CONTAINER_NAME"
log "Image: $IMAGE_NAME"
log "Trigger file: $TRIGGER_FILE"
log "Check interval: ${CHECK_INTERVAL}s"
log "Compose project: $COMPOSE_PROJECT_DIR"
log "=================================================="
# Warn if running from the legacy script path
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || echo "$0")"
case "$SCRIPT_PATH" in
/data/scripts/*)
log_warn "=================================================="
log_warn "Running from legacy path: $SCRIPT_PATH"
log_warn "Please update your docker-compose.upgrade.yml to use:"
log_warn " command: /data/.meshmonitor-internal/upgrade-watchdog.sh"
log_warn "See: https://github.com/Yeraze/meshmonitor/blob/main/docker-compose.upgrade.yml"
log_warn "=================================================="
;;
esac
# Initialize status
write_status "ready"
while true; do
# Check for Docker socket test request
if [ -f "$DOCKER_SOCKET_TEST_REQUEST" ]; then
log "Docker socket test request detected"
if [ -f "$DOCKER_SOCKET_TEST_SCRIPT" ]; then
# Make script executable and run it
chmod +x "$DOCKER_SOCKET_TEST_SCRIPT"
if sh "$DOCKER_SOCKET_TEST_SCRIPT"; then
log_success "Docker socket test completed"
else
log_warn "Docker socket test completed with warnings/errors"
fi
else
log_error "Docker socket test script not found: $DOCKER_SOCKET_TEST_SCRIPT"
echo "FAIL: Test script not found at $DOCKER_SOCKET_TEST_SCRIPT" > /data/.docker-socket-test
fi
# Clean up test request
rm -f "$DOCKER_SOCKET_TEST_REQUEST"
fi
# Check for upgrade trigger
if [ -f "$TRIGGER_FILE" ]; then
log "Upgrade trigger detected!"
if perform_upgrade; then
log_success "Upgrade process completed"
else
log_error "Upgrade process failed"
fi
# Clean up
rm -f "$TRIGGER_FILE"
fi
sleep "$CHECK_INTERVAL"
done
}
# Handle signals
trap 'log "Shutting down watchdog..."; exit 0' SIGTERM SIGINT
# Run main loop
main