meshtastic: add stack to repo, gitignore tiles, add scripts dir placeholder
This commit is contained in:
1
meshtastic/.gitignore
vendored
Normal file
1
meshtastic/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
tiles/
|
||||||
93
meshtastic/docker-compose.yaml
Normal file
93
meshtastic/docker-compose.yaml
Normal file
@@ -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
|
||||||
0
meshtastic/scripts/.gitkeep
Normal file
0
meshtastic/scripts/.gitkeep
Normal file
673
meshtastic/scripts/PirateWeatherADV.py
Executable file
673
meshtastic/scripts/PirateWeatherADV.py
Executable 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
487
meshtastic/scripts/mm_wx.py
Executable 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())
|
||||||
570
meshtastic/scripts/upgrade-watchdog.sh
Executable file
570
meshtastic/scripts/upgrade-watchdog.sh
Executable 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
|
||||||
Reference in New Issue
Block a user