From 0c87184ec413f73cfdfc350ceac1b8ea6bd5ff32 Mon Sep 17 00:00:00 2001 From: Paccoco <99041736+Paccoco@users.noreply.github.com> Date: Tue, 5 May 2026 12:15:05 -0500 Subject: [PATCH] meshtastic: add stack to repo, gitignore tiles, add scripts dir placeholder --- meshtastic/.gitignore | 1 + meshtastic/docker-compose.yaml | 93 ++++ meshtastic/scripts/.gitkeep | 0 meshtastic/scripts/PirateWeatherADV.py | 673 +++++++++++++++++++++++++ meshtastic/scripts/mm_wx.py | 487 ++++++++++++++++++ meshtastic/scripts/upgrade-watchdog.sh | 570 +++++++++++++++++++++ 6 files changed, 1824 insertions(+) create mode 100644 meshtastic/.gitignore create mode 100644 meshtastic/docker-compose.yaml create mode 100644 meshtastic/scripts/.gitkeep create mode 100755 meshtastic/scripts/PirateWeatherADV.py create mode 100755 meshtastic/scripts/mm_wx.py create mode 100755 meshtastic/scripts/upgrade-watchdog.sh diff --git a/meshtastic/.gitignore b/meshtastic/.gitignore new file mode 100644 index 0000000..3af91ac --- /dev/null +++ b/meshtastic/.gitignore @@ -0,0 +1 @@ +tiles/ diff --git a/meshtastic/docker-compose.yaml b/meshtastic/docker-compose.yaml new file mode 100644 index 0000000..11222ed --- /dev/null +++ b/meshtastic/docker-compose.yaml @@ -0,0 +1,93 @@ +services: + meshmonitor: + image: ghcr.io/yeraze/meshmonitor:4.2.0 + container_name: meshmonitor + ports: + - "8081:3001" + - "4404:4404" + restart: unless-stopped + volumes: + - /mnt/tank/docker/appdata/meshmonitor/data:/data + - ./scripts:/data/scripts + env_file: .env + environment: + - NODE_ENV=production + - TZ=America/Chicago + - MESHTASTIC_NODE_IP=10.5.1.172 + - TRUST_PROXY=true + - COOKIE_SECURE=true + - ALLOWED_ORIGINS=https://meshmonitor.paccoco.com + - AUTO_UPGRADE_ENABLED=true + - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@shared-postgres:5432/meshmonitor + - PIRATE_WEATHER_API_KEY=${PIRATE_WEATHER_API_KEY} + - LOCAL_LAT=${LOCAL_LAT} + - LOCAL_LON=${LOCAL_LON} + networks: + - default + - ix-databases_shared-databases + - pangolin + meshmonitor-upgrader: + image: docker:latest + container_name: meshmonitor-upgrader + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /mnt/tank/docker/appdata/meshmonitor/data:/data + - .:/compose:ro + environment: + - CONTAINER_NAME=meshmonitor + - IMAGE_NAME=ghcr.io/yeraze/meshmonitor + - TRIGGER_FILE=/data/.upgrade-trigger + - STATUS_FILE=/data/.upgrade-status + - CHECK_INTERVAL=5 + - COMPOSE_PROJECT_DIR=/mnt/docker-ssd/docker/compose/meshtastic + - COMPOSE_PROJECT_NAME=meshmonitor + command: /data/.meshmonitor-internal/upgrade-watchdog.sh + depends_on: + - meshmonitor + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + tileserver: + image: maptiler/tileserver-gl-light:latest + container_name: meshmonitor-tileserver + ports: + - "8082:8080" + restart: unless-stopped + volumes: + - ./tiles:/data + environment: + - VERBOSE=true + command: --verbose + networks: + - default + - pangolin + mqtt-proxy: + image: ghcr.io/ln4cy/mqtt-proxy:master + container_name: meshmonitor-mqtt-proxy + restart: unless-stopped + environment: + - INTERFACE_TYPE=tcp + - TCP_NODE_HOST=meshmonitor + - TCP_NODE_PORT=4404 + - LOG_LEVEL=INFO + - TCP_TIMEOUT=300 + - CONFIG_WAIT_TIMEOUT=60 + - HEALTH_CHECK_ACTIVITY_TIMEOUT=300 + depends_on: + - meshmonitor + healthcheck: + test: ["CMD-SHELL", "test -f /tmp/healthy && find /tmp/healthy -mmin -1 | grep -q healthy"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + networks: + - default +networks: + ix-databases_shared-databases: + external: true + pangolin: + external: true diff --git a/meshtastic/scripts/.gitkeep b/meshtastic/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/meshtastic/scripts/PirateWeatherADV.py b/meshtastic/scripts/PirateWeatherADV.py new file mode 100755 index 0000000..152f19b --- /dev/null +++ b/meshtastic/scripts/PirateWeatherADV.py @@ -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() diff --git a/meshtastic/scripts/mm_wx.py b/meshtastic/scripts/mm_wx.py new file mode 100755 index 0000000..1dbfed1 --- /dev/null +++ b/meshtastic/scripts/mm_wx.py @@ -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 -> 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 ") + 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()) diff --git a/meshtastic/scripts/upgrade-watchdog.sh b/meshtastic/scripts/upgrade-watchdog.sh new file mode 100755 index 0000000..27bc40e --- /dev/null +++ b/meshtastic/scripts/upgrade-watchdog.sh @@ -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