Skip to content
Open
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
10 changes: 9 additions & 1 deletion docs/specs/external-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,18 @@ The core widget renders the base fields as today and the extensions **when prese

**4. The weather-alert scene trigger (core-owned — works with every provider, zero integration contract)**. The field need (the MF pilot again): "run a scene when the vigilance level raises". The trigger is fed by the **core**, not by a push channel: a scheduled job (`check-weather-alerts`, every 30 min) — **gated**: it runs only when at least one active scene carries a weather-alert trigger, so users without such scenes cost their provider zero extra API calls — pulls the weather of every house with coordinates through the normal provider loop, and **diffs the normalized alerts** against the previous poll. Diff identity: the alert `type` when present, the trimmed lowercased `event` text otherwise — one more reason `type` exists. Severity is ranked `minor < moderate < severe < extreme`; a new alert **or a severity increase** fires `weather.alert-raised`, a disappeared alert fires `weather.alert-ended`, a de-escalation that does not clear the alert fires nothing (the alert is still on — re-announcing it adds noise, its end will fire). The first poll after a core start is a **baseline**: no events (a restart during an ongoing storm must not re-fire every scene). Trigger configuration: house, optional phenomenon type filter (`any` by default), minimal severity (`minor` by default). Because the diff runs on **normalized pivot alerts**, the trigger works identically for every provider — OpenWeather One Call alerts included — and the integration has nothing to implement.

**5. The freshness nudge — "trigger, not data" (B.17 doctrine, transposed)**. 30 min is the floor without integration cooperation; a provider that *knows* something changed can do better, but never by pushing data: **`external-integration.weather.refresh`** (integration → core, empty payload, no `message_id`, no ack — fire-and-forget) means only "re-pull me now". The core then runs the exact same gated check as the scheduled job: the data re-enters through the audited pull path (`weather.get` → `normalizeWeather` → diff), the push channel carries **zero state**. Accepted from "weather" integrations only; rate-limited to 1 per minute per integration, silently dropped beyond (fire-and-forget has no error path, and a dropped nudge costs at most the 30-min floor). The checks are **serialized**: a nudge (or the scheduled job) landing while a check is still in flight is dropped — two overlapping runs would diff the same baseline and fire every transition twice. The MF integration polls vigilance upstream, nudges on change: the scene fires seconds later.
**5. The freshness nudge — "trigger, not data" (B.17 doctrine, transposed)**. The scheduled floors are 30 min for the alert diff (point 4) and 15 min for the weather scene trigger (point 7); without integration cooperation, that is as fresh as it gets. A provider that *knows* something changed can do better, but never by pushing data: **`external-integration.weather.refresh`** (integration → core, empty payload, no `message_id`, no ack — fire-and-forget) means only "re-pull me now". The core then runs the exact same gated checks as the scheduled jobs: the data re-enters through the audited pull path (`weather.get` → `normalizeWeather` → diff), the push channel carries **zero state**. Accepted from "weather" integrations only; rate-limited to 1 per minute per integration, silently dropped beyond (fire-and-forget has no error path, and a dropped nudge costs at most the floor of the check it would have relaunched). Each check is **serialized with itself**: a nudge (or the scheduled job) landing while that check is still in flight is dropped — two overlapping runs of the same check would diff the same baseline and fire every transition twice. The two checks, on the other hand, do run **concurrently** — they have different floors and coincide every half hour, and the nudge relaunches both — so they **share their provider pulls**: while any check run is in flight, a house is pulled once and the payload is handed to both (the sharing window is exactly the overlap of the runs, so no check ever compares against data older than its own poll). Two check types therefore cost one `weather.get` per house, not two. The MF integration polls vigilance upstream, nudges on change: the scene fires seconds later.

**6. Provider images (vigilance map, rain radar, satellite view)**. The pivot format is deliberately numeric/textual — images do not belong in a weather payload, but weather providers genuinely have them (MF vigilance map, Buienradar/DWD/MeteoSwiss rain radars). The generic channel keeps the trust boundary intact: the payload only ever declares **metadata** — optional `images` (≤ 3 entries): `key` (`^[a-z0-9][a-z0-9-]{0,31}$`) and optional multi-language `label` (values ≤ 50 chars) — and the bytes travel **on demand** over `external-integration.weather.get-image` `{ message_id, key }` → `command-result` `data.image` (raw base64, no data-URI prefix; 15 s ack, the camera exception). The core validates the **decoded bytes** (PNG or JPEG magic numbers only, ≤ 500 KB), caches them 10 min per (integration, key), and serves them to the frontend from its own origin (`GET /api/v1/house/:house_selector/weather/image/:key`, standard user auth) as a data URI — the browser **never loads a third-party URL** (no user-IP leak on every dashboard render, the same doctrine as the catalog cover re-hosting, C.1). The widget gains one "provider images" toggle and renders every declared image with its label; a provider without images loses nothing. The request path is **allowlisted end to end**: the core shape-checks the requested key against the declaration regex before consulting any provider, and the proxy only relays a key **declared in the last normalized payload** of that integration — an undeclared key 404s without a single byte sent to the integration, so an authenticated caller can neither probe the integration with arbitrary keys nor grow the 10-min cache past the declared set (≤ 3 entries per integration, the images cap). Provider images are **instance-global, not per-house**: neither the route (whose house selector only scopes authentication and 404) nor `weather.get-image` carries coordinates — the provider serves the images it knows (a national vigilance map, or the area of the last `weather.get` it answered). If a per-house need materializes, threading the house coordinates into the command is a purely additive extension.

**7. The weather scene trigger (core-owned, same doctrine as point 4)**. The field need (community request, topic 10523): "close the blinds when the wind goes above 20 km/h, stop the watering when it rains, alert me when it freezes". Scope note from the request thread: the weather is deliberately **not** exposed as a device whose features would be usable everywhere — it is a **dedicated trigger**. A weather *condition* (evaluated inside a running scene) is a separate request and is not part of this point.

Same shape as the alert trigger, so it works with every provider and costs nothing to integrations: a scheduled job (`check-weather-triggers`, every 15 min — weather thresholds are more time-sensitive than vigilance bulletins) — **gated twice**: it runs only when at least one active scene carries a `weather.matched` trigger, and it then pulls **only the houses those triggers actually watch** (a trigger matches on its own house, so polling the others would buy nothing and cost a provider call every 15 min) — through the normal provider loop (`units: metric`), and emits the trigger check with **both the current and the previous payload**. When it overlaps the alert check, the pull of a house is shared between the two (point 5). The first poll after a core start is a **baseline**: no event, for the same reason as point 4 — and a house that leaves the watched set (scene deactivated, trigger removed) drops its baseline, so it re-baselines when it comes back instead of comparing against a payload from another day.

Trigger configuration: house, watched pivot property (`temperature`, `wind_speed`, `humidity`, `condition`), and the **shared `operator` / `value` couple of the threshold triggers** (`=`, `!=`, `>`, `>=`, `<`, `<=` — no new operator vocabulary). Compared values: °C for the temperature, % for the humidity, the pivot **condition enum** as a string for the condition, and **km/h** for the wind speed — the pivot carries m/s, the trigger converts it exactly like the dashboard widget, because km/h is the unit users write their rules in. The numbers are compared **as the widget displays them**, rounded (`Math.round`, the widget's own formatting): a rule is written from what the dashboard shows, so 5.55 m/s — displayed as 20 km/h — matches `>= 20` instead of comparing 19.98 behind the user's back. A property the provider does not expose never matches, and a numeric rule whose value is empty or unparseable never matches. The stored value is always metric; the scene editor **displays and reads it in the unit system of the user** (`temperature_unit_preference`, `distance_unit_preference` — °F and mph shown and converted back on save), so a threshold copied from an imperial dashboard means what it reads. The threshold lives on the **same rounded grid as the compared value**: the **matcher** rounds the threshold it reads as well as the value it observes, otherwise `>= 20 mph` — stored as 32.1868 km/h — would never match the 32 km/h the widget displays for that wind. Rounding therefore belongs to the comparison, not to the storage: the editor stores the °F → °C conversion **unrounded**, so that what the user typed is what they read back (rounding it would turn 71 °F into 22 °C, redisplayed as 72 °F on the next open, without changing a single match).

**Edge-triggered, never repeating**: the scene runs when the rule matches on the current payload and did **not** match on the previous one. So "wind > 20 km/h → close the blinds" runs once when the wind picks up, not every 15 minutes for as long as it blows. Because both payloads travel in the event, the matcher is stateless: editing a scene, adding a trigger or changing a threshold resets nothing.

**What v1 anticipated (and it held)**: extensible manifest `type`, catalog filter by type, WS `<domain>.<action>` convention, proxy service extensible by capability (`device.*`, `message.*`, now `weather.*`). Phase 3: deprecation of the core `openweather` service in favor of a community-maintained external equivalent (same path as the communication services in B.15).

### B.20 Docker image cleanup
Expand Down
21 changes: 20 additions & 1 deletion front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -3512,7 +3512,8 @@
},
"weather": {
"alert-raised": "Wetterwarnung ausgelöst",
"alert-ended": "Wetterwarnung beendet"
"alert-ended": "Wetterwarnung beendet",
"matched": "Wetter"
}
},
"triggersCard": {
Expand Down Expand Up @@ -3645,6 +3646,24 @@
"severe": "Schwer (orange)",
"extreme": "Extrem (rot)"
}
},
"weather": {
"description": "Dieser Auslöser wird ausgelöst, sobald das Wetter des ausgewählten Hauses die untenstehende Regel erfüllt. Das Wetter wird alle 15 Minuten geprüft: Die Szene wird nur beim Wechsel ausgeführt und startet daher nicht erneut, solange die Regel weiterhin zutrifft.",
"houseLabel": "Haus",
"fieldLabel": "Wetterwert",
"fields": {
"temperature": "Temperatur",
"wind_speed": "Windgeschwindigkeit",
"humidity": "Luftfeuchtigkeit",
"condition": "Wetterlage"
},
"operatorLabel": "Operator",
"valueLabel": "Wert",
"unitTemperature": "°C",
"unitTemperatureImperial": "°F",
"unitWindSpeed": "km/h",
"unitWindSpeedImperial": "mph",
"unitHumidity": "%"
}
}
},
Expand Down
21 changes: 20 additions & 1 deletion front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3512,7 +3512,8 @@
},
"weather": {
"alert-raised": "Weather alert raised",
"alert-ended": "Weather alert ended"
"alert-ended": "Weather alert ended",
"matched": "Weather"
}
},
"triggersCard": {
Expand Down Expand Up @@ -3645,6 +3646,24 @@
"severe": "Severe (orange)",
"extreme": "Extreme (red)"
}
},
"weather": {
"description": "This will trigger when the weather of the selected house starts matching the rule below. The weather is checked every 15 minutes: the scene runs on the transition only, so it does not run again for as long as the rule stays true.",
"houseLabel": "House",
"fieldLabel": "Weather property",
"fields": {
"temperature": "Temperature",
"wind_speed": "Wind speed",
"humidity": "Humidity",
"condition": "Weather condition"
},
"operatorLabel": "Operator",
"valueLabel": "Value",
"unitTemperature": "°C",
"unitTemperatureImperial": "°F",
"unitWindSpeed": "km/h",
"unitWindSpeedImperial": "mph",
"unitHumidity": "%"
}
}
},
Expand Down
21 changes: 20 additions & 1 deletion front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -3512,7 +3512,8 @@
},
"weather": {
"alert-raised": "Alerte météo déclenchée",
"alert-ended": "Alerte météo terminée"
"alert-ended": "Alerte météo terminée",
"matched": "Météo"
}
},
"triggersCard": {
Expand Down Expand Up @@ -3645,6 +3646,24 @@
"severe": "Sévère (orange)",
"extreme": "Extrême (rouge)"
}
},
"weather": {
"description": "Ce déclencheur se lance quand la météo de la maison sélectionnée commence à vérifier la règle ci-dessous. La météo est vérifiée toutes les 15 minutes : la scène se lance uniquement au changement, elle ne se relance donc pas tant que la règle reste vraie.",
"houseLabel": "Maison",
"fieldLabel": "Donnée météo",
"fields": {
"temperature": "Température",
"wind_speed": "Vitesse du vent",
"humidity": "Humidité",
"condition": "Condition météo"
},
"operatorLabel": "Opérateur",
"valueLabel": "Valeur",
"unitTemperature": "°C",
"unitTemperatureImperial": "°F",
"unitWindSpeed": "km/h",
"unitWindSpeedImperial": "mph",
"unitHumidity": "%"
}
}
},
Expand Down
11 changes: 10 additions & 1 deletion front/src/routes/scene/edit-scene/TriggerCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import CalendarEventIsComing from './triggers/CalendarEventIsComing';
import AlarmModeTrigger from './triggers/AlarmModeTrigger';
import MQTTReceivedTrigger from './triggers/MQTTReceivedTrigger';
import WeatherAlert from './triggers/WeatherAlert';
import WeatherTrigger from './triggers/WeatherTrigger';

import { EVENTS } from '../../../../../server/utils/constants';
import GladysStartTrigger from './triggers/GladysStartTrigger';
Expand All @@ -36,7 +37,8 @@ const TRIGGER_ICON = {
[EVENTS.SYSTEM.START]: 'fe-activity',
[EVENTS.MQTT.RECEIVED]: 'fe-hash',
[EVENTS.WEATHER.ALERT_RAISED]: 'fe-alert-triangle',
[EVENTS.WEATHER.ALERT_ENDED]: 'fe-check-circle'
[EVENTS.WEATHER.ALERT_ENDED]: 'fe-check-circle',
[EVENTS.WEATHER.MATCHED]: 'fe-cloud'
};

const WEATHER_ALERT_TRIGGERS = [EVENTS.WEATHER.ALERT_RAISED, EVENTS.WEATHER.ALERT_ENDED];
Expand Down Expand Up @@ -182,6 +184,13 @@ const TriggerCard = ({ children, ...props }) => (
{WEATHER_ALERT_TRIGGERS.includes(props.trigger.type) && (
<WeatherAlert updateTriggerProperty={props.updateTriggerProperty} index={props.index} trigger={props.trigger} />
)}
{props.trigger.type === EVENTS.WEATHER.MATCHED && (
<WeatherTrigger
updateTriggerProperty={props.updateTriggerProperty}
index={props.index}
trigger={props.trigger}
/>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ const TRIGGER_LIST = [
EVENTS.SYSTEM.START,
EVENTS.MQTT.RECEIVED,
EVENTS.WEATHER.ALERT_RAISED,
EVENTS.WEATHER.ALERT_ENDED
EVENTS.WEATHER.ALERT_ENDED,
EVENTS.WEATHER.MATCHED
];

class ChooseTriggerType extends Component {
Expand Down
Loading
Loading