Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/developers/auto-responder-scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/features/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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**:
Expand Down Expand Up @@ -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**:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/user-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 90 additions & 31 deletions examples/auto-responder-scripts/PirateWeatherADV.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,63 @@
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.
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
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).

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -123,9 +150,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']
Expand Down Expand Up @@ -182,7 +219,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']))
Expand All @@ -200,7 +237,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 {}
Expand Down Expand Up @@ -235,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)
Expand Down Expand Up @@ -275,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
Expand Down Expand Up @@ -351,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()
Expand Down Expand Up @@ -392,7 +447,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 {}
Expand Down Expand Up @@ -662,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)
Expand Down
2 changes: 1 addition & 1 deletion examples/auto-responder-scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading