From 882e3329a6364948686609e8576e056ee7d84cc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 21:37:18 +0000 Subject: [PATCH 1/2] fix(scripts): bump PirateWeatherADV timeouts to v3, correct 10s->30s docs The bundled community script's per-request Nominatim/Pirate Weather urlopen timeouts (3s/4s/3s) were sized against a stale "10-second script timeout" assumption. The Auto-Responder and Timed Event script kill timeout is actually 30 seconds (execFileAsync timeout in meshtasticManager.ts), so a slow upstream response could exceed the old per-call socket timeouts well before the real kill signal, surfacing as "The read operation timed out". Bumps timeouts to 5s/8s/5s as named constants (community-submitted v3) and corrects the "10-second" claim to 30 seconds everywhere else it was documented. Closes #3941 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Shsuu7NEH9Z7w1va1YcpZj --- CHANGELOG.md | 3 +++ docs/developers/auto-responder-scripting.md | 4 ++-- docs/features/automation.md | 10 ++++----- docs/user-scripts.md | 2 +- .../PirateWeatherADV.py | 22 ++++++++++++++----- examples/auto-responder-scripts/README.md | 2 +- 6 files changed, 28 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6db93e4..05ac3ba25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed +- **PirateWeatherADV example script: "read operation timed out" under Timed Events** — The bundled community script's per-request Nominatim/Pirate Weather `urlopen` timeouts (3s/4s/3s) were based on a stale "10-second script timeout" assumption; the actual Auto-Responder/Timed Event script kill timeout is 30 seconds. A slow upstream response could exceed the old socket timeouts long before the real kill signal, surfacing as `The read operation timed out`. Timeouts are bumped to 5s/8s/5s (named constants) and the misleading "10-second" comment/docs are corrected to 30 seconds throughout. (#3941) + ## [4.12.4] - 2026-07-03 ### Added diff --git a/docs/developers/auto-responder-scripting.md b/docs/developers/auto-responder-scripting.md index faca3351b..93eca79c3 100644 --- a/docs/developers/auto-responder-scripting.md +++ b/docs/developers/auto-responder-scripting.md @@ -117,7 +117,7 @@ Hello Alice! You sent: hello Alice ✅ **Location:** Scripts must be in `/data/scripts/` directory ✅ **Extension:** `.js`, `.mjs`, `.py`, or `.sh` ✅ **Output:** Valid JSON to stdout with `response` field -✅ **Timeout:** Complete within 10 seconds +✅ **Timeout:** Complete within 30 seconds ✅ **Executable:** Have execute permissions (`chmod +x`) ### Script Metadata (mm_meta) @@ -1049,7 +1049,7 @@ docker exec meshmonitor sh -c 'export MESSAGE="info" FROM_NODE="123" && /bin/sh ✅ Scripts run as `node` user (not root) ✅ Limited to `/data/scripts/` directory ✅ Path traversal attempts (`..`) are blocked -✅ 10-second execution timeout +✅ 30-second execution timeout ✅ Output limited to 1MB ### Best Practices diff --git a/docs/features/automation.md b/docs/features/automation.md index f66f8e9c2..0b109c823 100644 --- a/docs/features/automation.md +++ b/docs/features/automation.md @@ -787,7 +787,7 @@ You can specify custom regex patterns for parameters using `{paramName:regex}` s - Supports Node.js (`.js`, `.mjs`), Python (`.py`), and Shell (`.sh`) scripts - Scripts receive message data and parameters via environment variables - Can output single or multiple responses (see [Script Response Details](#script-response-details) below) -- 10-second execution timeout +- 30-second execution timeout - **Script Arguments**: Optional command-line arguments with token expansion (see below) - Example trigger: `weather {location}` - Example response: `/data/scripts/weather.py` @@ -1060,7 +1060,7 @@ Scripts must: - Be located in `/data/scripts/` directory - Have a supported extension: `.js`, `.mjs`, `.py`, or `.sh` - Output valid JSON to stdout with a `response` or `responses` field (see details in [Multiple Responses Support](#multiple-responses-support) section) -- Complete execution within 10 seconds (timeout) +- Complete execution within 30 seconds (timeout) - Handle errors gracefully **Environment Variables**: @@ -1175,7 +1175,7 @@ echo "Debug: $VARIABLE" >&2 # Shell - Scripts are sandboxed to `/data/scripts/` directory - Path traversal attempts (`..`) are blocked - Scripts run with container user permissions (not root) -- 10-second execution timeout prevents runaway scripts +- 30-second execution timeout prevents runaway scripts - Output limited to 1MB to prevent memory issues **Performance Tips**: @@ -1423,7 +1423,7 @@ Timer trigger scripts follow the same requirements as Auto Responder scripts: - Located in `/data/scripts/` directory - Supported extensions: `.js`, `.mjs`, `.py`, `.sh` - Must output valid JSON to stdout with a `response` or `responses` field -- 10-second execution timeout +- 30-second execution timeout - Execute with container user permissions - **Script Arguments**: Optional command-line arguments with token expansion @@ -1693,7 +1693,7 @@ Alert: {SHORT_NAME} has left the monitored area - Scripts must be in `/data/scripts/` directory - Supports Node.js, Python, and Shell scripts - Receives geofence data via environment variables -- Same 10-second timeout as Auto Responder scripts +- Same 30-second timeout as Auto Responder scripts - **Script Arguments**: Optional command-line arguments with token expansion ### Script Arguments (Geofence Triggers) diff --git a/docs/user-scripts.md b/docs/user-scripts.md index d292f5ec2..91de3efe1 100644 --- a/docs/user-scripts.md +++ b/docs/user-scripts.md @@ -64,7 +64,7 @@ All scripts must: - Be located in `/data/scripts/` directory - Have a supported extension: `.js`, `.mjs`, `.py`, or `.sh` - Output valid JSON to stdout with a `response` field (or `responses` array) -- Complete within 10 seconds (timeout) +- Complete within 30 seconds (timeout) - Be executable (`chmod +x`) ### Output Format diff --git a/examples/auto-responder-scripts/PirateWeatherADV.py b/examples/auto-responder-scripts/PirateWeatherADV.py index 152f19b56..d6075beef 100755 --- a/examples/auto-responder-scripts/PirateWeatherADV.py +++ b/examples/auto-responder-scripts/PirateWeatherADV.py @@ -123,9 +123,19 @@ 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 +# MeshMonitor enforces a 200-char max per message. +# The Auto-Responder hard kills scripts after 30 seconds (execFileAsync timeout). +# We set individual HTTP timeouts conservatively so the total worst-case network +# time stays well under 20s, leaving a safe buffer before the 30s kill signal: +# Nominatim geocode: TIMEOUT_GEOCODE seconds +# Pirate Weather API: TIMEOUT_API seconds +# Nominatim reverse geocode: TIMEOUT_REVERSE seconds +# Worst-case total: sum of all three +# Adjust these if you are on a slow or high-latency connection. +MSG_LIMIT = 200 +TIMEOUT_GEOCODE = 5 # forward geocode (Nominatim) +TIMEOUT_API = 8 # Pirate Weather API fetch +TIMEOUT_REVERSE = 5 # reverse geocode (Nominatim) # Day-of-week abbreviations indexed by Python's tm_wday (0=Mon … 6=Sun) DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] @@ -182,7 +192,7 @@ def geocode_location(location: str) -> Optional[Tuple[float, float]]: 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: + with urllib.request.urlopen(req, timeout=TIMEOUT_GEOCODE) as resp: data = json.loads(resp.read().decode('utf-8')) if data: return (float(data[0]['lat']), float(data[0]['lon'])) @@ -200,7 +210,7 @@ def reverse_geocode_city(lat: float, lon: float) -> str: 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: + with urllib.request.urlopen(req, timeout=TIMEOUT_REVERSE) as resp: data = json.loads(resp.read().decode('utf-8')) address = data.get('address') or {} @@ -392,7 +402,7 @@ def get_weather(lat: float, lon: float) -> Dict[str, Any]: 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: + with urllib.request.urlopen(req, timeout=TIMEOUT_API) as resp: data = json.loads(resp.read().decode('utf-8')) current = data.get('currently') or {} diff --git a/examples/auto-responder-scripts/README.md b/examples/auto-responder-scripts/README.md index b393498cb..f10ddacef 100644 --- a/examples/auto-responder-scripts/README.md +++ b/examples/auto-responder-scripts/README.md @@ -67,7 +67,7 @@ Scripts must: 1. Be located in `/data/scripts/` directory 2. Have a supported extension: `.js`, `.mjs`, `.py`, or `.sh` 3. Output valid JSON to stdout with a `response` field -4. Complete within 10 seconds (timeout) +4. Complete within 30 seconds (timeout) ## Environment Variables From 6349606e3a6d1eb994d2a5960d6ad6a1ee8ad9ec Mon Sep 17 00:00:00 2001 From: Randall Hand Date: Sun, 5 Jul 2026 19:02:59 -0400 Subject: [PATCH 2/2] fix(scripts): fold PirateWeatherADV v3 follow-up fixes + docstrings Submitter posted an improved v3 attachment on #3941 (verified by NodeZero's review) with three low-risk fixes over the version already in this PR: - _fmt_label/_fmt_part: 1-3 letter alpha tokens are now uppercased as abbreviations regardless of input case (on -> ON), not just preserved when already uppercase. - resolve_input: guard so a location that normalises to empty (e.g. just commas) falls through to the GPS-coordinate fallback instead of calling geocode_location(''). - main() exception handler: redact PIRATE_WEATHER_API_KEY from any exception text before broadcasting the error on the mesh (key-leak guard). Also applies the module-docstring improvements from the attachment and the two function-docstring corrections NodeZero flagged (_fmt_label and resolve_input), which the attachment itself had not updated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n --- .../PirateWeatherADV.py | 99 ++++++++++++++----- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/examples/auto-responder-scripts/PirateWeatherADV.py b/examples/auto-responder-scripts/PirateWeatherADV.py index d6075beef..8d5e68254 100755 --- a/examples/auto-responder-scripts/PirateWeatherADV.py +++ b/examples/auto-responder-scripts/PirateWeatherADV.py @@ -24,24 +24,41 @@ 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% + The forecast always covers a full 7-day lookahead from today. The API is + queried for 8 daily periods so that today (index 0) plus 7 future days are + always available regardless of the day of the week. + 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 + 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: + If no GPS data is available the script returns a helpful error message. + GPS mode output uses emoji format with a reverse-geocoded city name: 📍 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 +INPUT FORMATS: + Spaces after commas are normalised — all of these produce identical results: + weather peterborough,ontario,canada + weather peterborough, ontario, canada + w peterborough, ontario, canada + forecast peterborough, ontario, canada + f peterborough, ontario, canada + + Location label formatting: + - Comma-separated parts that are 1–3 letters are treated as abbreviations + and always uppercased regardless of how they were typed: + "london,on,canada" → "London, ON, Canada" + "london,uk" → "London, UK" + "new york,ny,usa" → "New York, NY, USA" + - Multi-word parts are title-cased preserving apostrophes: + "st. john's,nl,canada" → "St. John's, NL, Canada" + - Malformed input (empty parts, trailing commas) is silently filtered. + If the location reduces to nothing after filtering, the script falls + back to GPS mode rather than returning an error. MODE DETECTION ORDER (how the script decides weather vs forecast): 1. TRIGGER env var — MeshMonitor sets this to the matched trigger pattern. @@ -49,11 +66,21 @@ 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 + Can override TRIGGER — use with care. + 4. Leading keyword — Keyword at the start of the CLI arg or PARAM_location (e.g. "forecast peterborough,ontario,canada"). 5. Default — 'weather' if none of the above match. +TIMEOUTS: + Individual HTTP timeouts are set via named constants (see Configuration + section below). Defaults: geocode=5s, API=8s, reverse geocode=5s. + Worst-case total: 18s — well inside MeshMonitor's 30s hard kill limit. + Adjust TIMEOUT_* constants in the Configuration section for your connection. + +SECURITY: + The Pirate Weather API key is automatically redacted from any error message + before it is broadcast on the mesh channel, protecting it from exposure. + All weather data is sourced exclusively from Pirate Weather (pirateweather.net). Location names are resolved via OpenStreetMap Nominatim (free, no API key needed). @@ -86,11 +113,11 @@ 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. + The comma-separated patterns in each trigger field handle both the bare + keyword (GPS mode) and the keyword + location variants in a single entry. + MeshMonitor sets the TRIGGER env var to the matched pattern, which the + script uses to reliably determine weather vs forecast mode even when + PARAM_location no longer contains the keyword (MeshMonitor strips it). Local testing: Current conditions by location: @@ -99,7 +126,7 @@ 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): + GPS mode — forecast (simulate TRIGGER env var from MeshMonitor): 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 @@ -245,18 +272,28 @@ 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 + - Short (1-3 letter) alpha tokens are uppercased as abbreviations, + regardless of input case (on -> ON, ny -> NY, CA -> CA) - 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() + if not words: + return '' + # If this comma-separated part is a single short token (1-3 alpha chars), + # treat it as an abbreviation and uppercase it regardless of input case. + # e.g. "on" → "ON", "uk" → "UK", "usa" → "USA", "CA" → "CA". + # Multi-word parts (e.g. "new york", "st. john's") are title-cased + # word by word to preserve apostrophes and avoid mangling. + if len(words) == 1 and len(words[0]) <= 3 and words[0].replace('.', '').isalpha(): + return words[0].upper() out = [] for w in words: if not w: continue if w.isupper() and len(w) <= 3: - out.append(w) + out.append(w) # preserve already-uppercase abbreviation else: out.append(w[0].upper() + w[1:]) return ' '.join(out) @@ -285,6 +322,8 @@ def resolve_input() -> Tuple[Optional[Tuple[float, float]], Optional[str], str, Resolution order for location: 1. CLI arguments (e.g. Timed Events) 2. PARAM_location env var (trigger pipeline) + If the location string normalises to nothing (e.g. just commas), + resolution falls through to GPS coordinates below instead of erroring. Resolution order for coordinates when no location string given: 3. FROM_LAT / FROM_LON — requesting node GPS @@ -361,12 +400,18 @@ def resolve_input() -> Tuple[Optional[Tuple[float, float]], Optional[str], str, # 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') + + # Guard: if normalisation left nothing (e.g. input was just commas), + # fall through to GPS mode rather than calling geocode with empty string + if not location_normalised: + location = '' else: - return (None, label, output_mode, 'location') + 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() @@ -672,7 +717,11 @@ def main(): print(f'\n--- WEATHER MSG ---\n{msg}\n--- END TEST ---\n', file=sys.stderr) except Exception as e: - emit([f'Error: {str(e)}']) + # Sanitise API key from exception message before broadcasting on mesh + err_msg = str(e) + if PIRATE_WEATHER_API_KEY: + err_msg = err_msg.replace(PIRATE_WEATHER_API_KEY, '***') + emit([f'Error: {err_msg}']) if TEST_MODE: import traceback traceback.print_exc(file=sys.stderr)