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
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,19 @@ Ecowitt:

Weather forecast:

- `sensorius/saiWeatherAlerts.py` runs as the supervised **NWS Weather Alerts**
task, independently of connected browsers. Only the `us` forecast provider
enables it. NWS validates the Astral point before the hub polls active
weather warnings/watches every 60 seconds. The `weather_alert_state` SQLite
table stores CAP/VTEC event identities, per-automation/actor receipts, and
pending dashboard Alert messages with separate durable dismissal state.
Automation monitors read the in-memory snapshot; they never fetch NWS.
- `/api/weather-alerts` serves the shared snapshot without upstream requests.
Dashboard and Caelus poll it every 15 seconds, filter expired events, and
update warning presentation without reloading the page. Pending weather Alerts
are replayed by this endpoint; `POST /api/weather-alerts/{alert_id}/dismiss`
records dismissal for all dashboards, independently of the trigger receipt.

- `sensorius/saiWeatherForecast.py` resolves the dashboard forecast location from Astral
settings or Astral auto-detection.
- The selected `[WeatherForecast].PROVIDER` controls the forecast source:
Expand Down
46 changes: 46 additions & 0 deletions docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Advanced rules can express:
- Day-of-week schedules.
- Sunrise and sunset schedules through Astral settings.
- Timer windows through `duration_min`, `period_min`, and legacy `freq_hours`.
- **Severe Weather**: active NWS weather warnings and watches at the Astral location.
- Multi-action rules.
- Email Notify actors with a per-action recipient when email is enabled.
- `Alert` actors for Web UI-only notifications without relay or email actions.
Expand Down Expand Up @@ -187,6 +188,51 @@ that all action rows in a rule share the same condition groups; adding a second
condition row does not bind one condition to one action and another condition
to another action.

## Severe Weather

Select **Severe Weather** as a condition. It has no additional parameters and
requires **US · National Weather Service** as the forecast provider. Sensorius
validates the Astral coordinates with the NWS point service and a US state or
territory before polling the location-filtered active-alert feed once per minute.
MET Norway, Open-Meteo, Disabled, and unsupported locations cannot trigger it.
Actual weather/geophysical warnings and watches qualify; test messages,
advisories, statements, future-effective and expired messages do not.

The condition can be combined with other conditions using the existing AND/OR
groups and can target any existing actor. Each automation/actor runs at most
once for a given event. NWS CAP references and VTEC identities correlate updates
with the original event. Trigger receipts live in SQLite `weather_alert_state`
and survive restarts and condition edits. Separate events can each trigger,
even when their periods overlap. Receipts are retained for 90 days after the
last sighting of an event. Explicitly disabling and saving an existing weather
automation, then enabling and saving it, rearms its actors for one new trigger
per active event. Saving an already-enabled rule, dismissing an Alert, restarting
the application, or receiving an NWS update does not rearm it.

- **Alert** displays the event type, headline, onset/end times in the Astral
timezone, expected duration, affected area, description, and instructions.
Dismissing this toast does not cause it to return on subsequent polls or a
restart. Pending weather Alerts persist in SQLite and are delivered when a
dashboard connects (within its 15-second refresh interval). Dismissal is saved
separately and applies to every dashboard. Expired or cancelled events are
no longer replayed. Older trigger receipts without a saved message cannot be
replayed; this delivery protection applies to newly triggered events.
- **Notify** uses the same details in an email and the event type in its subject.
The existing email delivery queue handles delivery attempts. No separate
recovery email is generated for this event condition. Once accepted into the
queue, the event receipt prevents re-enqueueing after restart; a process crash
before the queued email is sent can therefore lose that delivery.
- **Switch** respects the configured delay and reversion behavior. After the
event action succeeds, it is not repeatedly reasserted. A configured return
to previous state runs when the rule becomes false.

The dashboard forecast content and Caelus Forecast button remain orange while
qualifying warnings are valid, independently of whether an automation exists
or its toast has been dismissed. Caelus replaces its daily synopsis with all
active event details. Expiry/cancellation restores the normal forecast. On a
feed failure, previously received warnings remain visible only until their
expiry/end; Caelus marks their details as potentially stale.

## Astral Conditions

Astral conditions require location and timezone settings from:
Expand Down
19 changes: 19 additions & 0 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1238,6 +1238,25 @@ workspace and return to the dashboard.

Use switch overlays to answer practical questions: whether a fan cooled the greenhouse, whether irrigation raised soil moisture, or whether lights changed VPD.

## Severe Weather (US NWS)

When **US · National Weather Service** is selected, Sensorius checks the Astral
location against NWS coverage and polls for official warnings and watches.
During an active warning/watch, the dashboard forecast content and **Caelus
Forecast** button turn orange. Caelus replaces the daily forecast statement
with the event type, start/end times, expected duration, affected area and
NWS instructions. Normal presentation returns at cancellation or expiry.

To act on these events, create an automation with the **Severe Weather**
condition and choose any existing actor. **Alert** displays the detailed
message; **Notify** emails it when email is enabled. Each automation/actor
triggers only once per event, including across NWS updates and hub restarts.
You can dismiss an Alert toast without it appearing again for that event.
The orange forecast indication remains until the weather warning ends.
Advisories, statements, test messages, and other forecast providers do not
activate this condition. See [Severe Weather automation behavior](automations.md#severe-weather)
for delay, reversion, and delivery details.

## Caelus Weather Forecast

Select **6 Day Forecast** on the dashboard forecast tile to open the integrated
Expand Down
2 changes: 1 addition & 1 deletion sensorius/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
release notes, and supporting tooling can report a consistent build identity.
"""

__version__ = "v0.26.270.1"
__version__ = "v0.26.271.7"
4 changes: 4 additions & 0 deletions sensorius/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .saiSensorFactory import find_sensors
from .saiSwitchFactory import detect_relay_board
from .saiTimeSync import TimeSyncService
from .saiWeatherAlerts import WeatherAlertService
from .saiNodusAutomationStatus import NodusAutomationStatusPublisher
from .saiEmailNotifications import AutomationNotificationService, EmailNotificationService
from .saiInstanceLock import SensoriusInstanceLock
Expand Down Expand Up @@ -696,6 +697,9 @@ async def main(shutdown_requested: Event | None = None):
printDM("No SensorNetwork.BROKER configured — MQTT ingest not started.", location=f"{MODULE}:main")

# --- Always-on supervisors ---
weather_alerts = WeatherAlertService(settings=settings, data_logger=data_logger, supervisor=supervisor)
data_logger.weather_alert_service = weather_alerts
supervisor.add(weather_alerts.run, name="NWS Weather Alerts", fatal_on_timeout=False, fatal_on_error=False)
weewx_ingest = WeeWXArchiveIngest(settings=settings, data_logger=data_logger, supervisor=supervisor)
ecowitt_ingest = EcowittGatewayIngest(settings=settings, data_logger=data_logger, supervisor=supervisor)
ecowitt_ingest.switch_controllers = switch_controllers
Expand Down
28 changes: 26 additions & 2 deletions sensorius/saiAutomationManager.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,26 @@ def _as_enabled(value: Any) -> bool:
return value.strip().lower() not in {"0", "false", "off", "no", ""}
return bool(value)


def _preserve_weather_rearm(script: dict, previous_rule: dict | None, enabled: bool) -> None:
"""Rearm weather actors only on a saved disabled-to-enabled transition."""
try:
previous = json.loads((previous_rule or {}).get("script_json", "{}"))
except (ValueError, TypeError):
previous = {}
if not isinstance(previous, dict):
previous = {}
revision = previous.get("_weather_rearm_revision")
was_enabled = bool(previous_rule) and _as_enabled(previous_rule.get("enabled", False)) and _as_enabled(previous.get("enabled", True))
has_weather = any(isinstance(cond, dict) and cond.get("type") == "severe_weather"
for cond in (script.get("conditions") or []))
if previous_rule is not None and not was_enabled and enabled and has_weather:
revision = uuid.uuid4().hex
script.pop("_weather_rearm_revision", None)
if revision:
script["_weather_rearm_revision"] = revision


class AutomationManager:
"""
File layout:
Expand Down Expand Up @@ -459,7 +479,9 @@ def _mutate(data: Dict[str, Any]) -> bool:
if not found_here:
continue

# Outer enabled flag
# Preserve the saved state before applying the explicit toggle.
if isinstance(script, dict):
_preserve_weather_rearm(script, rule, enabled)
rule["enabled"] = bool(enabled)

# Inner script.enabled for consistency
Expand Down Expand Up @@ -667,6 +689,7 @@ def _mutate(data: Dict[str, Any]) -> None:
except (ValueError, TypeError):
incoming = previous = None
if isinstance(incoming, dict) and isinstance(previous, dict):
_preserve_weather_rearm(incoming, previous_rule, enabled)
revision = previous.get("_notification_revision")
has_notify = any(
str(action.get("type", "")).lower() == "notify"
Expand Down Expand Up @@ -735,15 +758,16 @@ def _mutate(data: Dict[str, Any]) -> bool:
if not isinstance(legacy_rule, dict):
return False
rule = dict(legacy_rule)
rule["enabled"] = bool(enabled)
# Keep inner payload in sync when possible.
try:
script = json.loads(str(rule.get("script_json", "")))
if isinstance(script, dict):
_preserve_weather_rearm(script, rule, enabled)
script["enabled"] = bool(enabled)
rule["script_json"] = json.dumps(script, separators=(",", ":"), ensure_ascii=False)
except Exception:
pass
rule["enabled"] = bool(enabled)
rules[rule_id] = rule
data[section] = rules
meta = data.get(SECTION_META, {}) or {}
Expand Down
24 changes: 24 additions & 0 deletions sensorius/saiDataLogger.py
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,13 @@ def _init_db(self):
ON biodynamic_calendar_cache(created_at DESC)
""")

cur.execute("""
CREATE TABLE IF NOT EXISTS weather_alert_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
payload TEXT NOT NULL
)
""")

# ---- email notification edge state -------------------
cur.execute("""
CREATE TABLE IF NOT EXISTS notification_rule_state (
Expand Down Expand Up @@ -1548,6 +1555,23 @@ def add_readings_listener(self, listener) -> None:
except Exception:
pass

def load_weather_alert_state(self) -> dict:
"""Load NWS event identities and once-per-actor trigger receipts."""
with self._open_conn() as conn:
row = conn.execute("SELECT payload FROM weather_alert_state WHERE id = 1").fetchone()
return json.loads(row[0]) if row else {}

def save_weather_alert_state(self, state: dict) -> None:
"""Atomically persist NWS event and automation deduplication state."""
with self._writer_lock:
self._ensure_writer()
self._writer_conn.execute(
"INSERT INTO weather_alert_state(id, payload) VALUES (1, ?) "
"ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
(json.dumps(state, separators=(",", ":")),),
)
self._writer_conn.commit()

def get_notification_rule_states(self) -> dict[str, bool]:
"""Return persisted active/normal state for email notification rules."""
try:
Expand Down
19 changes: 16 additions & 3 deletions sensorius/saiHtml.py
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,7 @@ def _db_label_map_for_switch(sw_id: str) -> dict[str, str]:
yield f"<link rel='stylesheet' href='/ui_static/css/mobile_settings.css?v={APP_VERSION}'>"
yield f"<script src='/ui_static/js/mobile_settings.js?v={APP_VERSION}'></script>"
yield f"<script src='/ui_static/weather_forecast/moon.js?v={APP_VERSION}' defer></script>"
yield f"<script src='/ui_static/js/weather_alerts.js?v={APP_VERSION}' defer></script>"
yield f"<script src='/ui_static/js/draggable_modals.js?v={APP_VERSION}'></script>"
yield f"<script type='module' src='/ui_static/js/advanced_automation.js?v={APP_VERSION}'></script>"
yield f"<script src='/ui_static/js/sensor_settings_modal.js?v={APP_VERSION}'></script>"
Expand Down Expand Up @@ -1747,6 +1748,8 @@ def _status_color_hex(status: str) -> str:
yield "#weatherForecastBox.forecast-scene-rain-day::after,#weatherForecastBox.forecast-scene-rain-night::after{inset:-18px 0 0;background:repeating-linear-gradient(105deg,transparent 0 15px,rgba(188,225,240,.5) 16px 17px,transparent 18px 29px);background-size:100% 42px;animation:forecast-rain-fall .8s linear infinite;opacity:.62;}"
yield "@keyframes forecast-rain-fall{to{transform:translateY(42px)}}"
yield "@media (prefers-reduced-motion:reduce){#weatherForecastBox.forecast-scene-rain-day::after,#weatherForecastBox.forecast-scene-rain-night::after{animation:none}}"
yield "#weatherForecastBox.severe-weather-active .forecast-current,#weatherForecastBox.severe-weather-active .forecast-open-btn{background:#ef8c23;color:#201307;border-color:#8c4300;}"
yield "#weatherForecastBox.severe-weather-active .forecast-current *{color:#201307;}"
yield "#weatherForecastBox .astro-card{position:relative;z-index:1;width:100%;min-width:0;align-items:stretch;box-sizing:border-box;text-align:left;height:100%;}"
yield "#weatherForecastBox .astro-title{width:100%;text-align:center;color:var(--forecast-ink);text-shadow:0 1px 2px rgba(0,0,0,.24);}"
yield ".forecast-status{font-size:.55rem;line-height:1.1;text-align:center;color:var(--forecast-muted);min-height:1.1em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}"
Expand Down Expand Up @@ -2315,7 +2318,7 @@ def _format_stat_value(stat_value):
yield ".toast{max-width:100%; padding:10px 14px; border-radius:8px; background:#222; color:#fff; box-shadow:0 2px 10px rgba(0,0,0,.25); text-align:center; pointer-events:auto}"
yield ".toast.ok{background:#1f693a}"
yield ".toast.error{background:#8b0000}"
yield ".toast.automation-notification-toast{background:#a34700;color:#fff;cursor:pointer}"
yield ".toast.automation-notification-toast{background:#ef8c23;color:#000;cursor:pointer}"
yield ".toast.email-failure-toast{cursor:pointer}"
yield ".onboard-overlay{z-index: 99990}" # ensure overlay is high
yield ".onboard-modal{position:relative; z-index: 99991}"
Expand Down Expand Up @@ -7426,6 +7429,7 @@ def _format_stat_value(stat_value):
yield " c.appendChild(t);"
yield " }"
yield " } else if (msg.type === 'automation_notification'){"
yield " if (msg.weather_alert_id && Array.from(document.querySelectorAll('[data-weather-alert-id]')).some(el => el.dataset.weatherAlertId === msg.weather_alert_id)) return;"
yield " const name = String(msg.name || msg.rule_id || 'Automation').trim();"
yield " const trigger = msg.trigger && typeof msg.trigger === 'object' ? msg.trigger : {};"
yield " const device = String(trigger.device || trigger.sensor_id || 'Unknown device').trim();"
Expand All @@ -7446,11 +7450,19 @@ def _format_stat_value(stat_value):
yield " if (c) {"
yield " const t = document.createElement('div');"
yield " t.className = 'toast automation-notification-toast';"
yield " if (msg.weather_alert_id) t.dataset.weatherAlertId = msg.weather_alert_id;"
yield " if (Array.isArray(msg.weather_event_ids) && msg.weather_event_ids.length) { t.style.maxHeight = '60vh'; t.style.overflowY = 'auto'; t.style.overflowWrap = 'anywhere'; t.setAttribute('tabindex', '0'); }"
yield " t.textContent = text;"
yield " t.style.whiteSpace = 'pre-line';"
yield " t.title = 'Click to dismiss';"
yield " t.setAttribute('role', 'status');"
yield " t.addEventListener('click', () => { t.remove(); if (!c.children.length) c.remove(); });"
yield " t.addEventListener('click', async () => {"
yield " if (msg.weather_alert_id) {"
yield " try { const response = await fetch('/api/weather-alerts/' + encodeURIComponent(msg.weather_alert_id) + '/dismiss', {method: 'POST'}); if (!response.ok) throw new Error('Dismissal failed'); }"
yield " catch (_) { t.title = 'Could not save dismissal. Click to retry.'; return; }"
yield " }"
yield " t.remove(); if (!c.children.length) c.remove();"
yield " });"
yield " c.appendChild(t);"
yield " }"
yield " } else if (msg.type === 'bd_transition'){"
Expand Down Expand Up @@ -7492,6 +7504,7 @@ def _format_stat_value(stat_value):
yield " }"
yield " } catch(e){ console.warn('Bad WS payload', e); }"
yield " };"
yield " window.receiveWeatherAlert = (msg) => switchWS.onmessage({data: JSON.stringify(msg)});"
yield " switchWS.onclose = () => {"
yield " console.warn('Switch WS closed; retrying...');"
yield " setTimeout(startSwitchWS, 2000);"
Expand Down Expand Up @@ -8236,7 +8249,7 @@ def core_helpers_html() -> str:
".toast{max-width:100%; padding:10px 14px; border-radius:8px; background:#222; color:#fff; box-shadow:0 2px 10px rgba(0,0,0,.25); text-align:center; pointer-events:auto}"
".toast.ok{background:#1f693a}"
".toast.error{background:#8b0000}"
".toast.automation-notification-toast{background:#a34700;color:#fff;cursor:pointer}"
".toast.automation-notification-toast{background:#ef8c23;color:#000;cursor:pointer}"
".toast.email-failure-toast{cursor:pointer}"
".onboard-overlay{z-index:99990}"
".onboard-modal{position:relative; z-index:99991}"
Expand Down
Loading
Loading