diff --git a/docs/specs/dashboard-flexible-layout-and-widgets.md b/docs/specs/dashboard-flexible-layout-and-widgets.md index eb9d06a9b5..700e6c20fb 100644 --- a/docs/specs/dashboard-flexible-layout-and-widgets.md +++ b/docs/specs/dashboard-flexible-layout-and-widgets.md @@ -191,6 +191,22 @@ The signature widget: an illustration (typically the user's house) with live dev - Assets are deleted with their dashboard — explicitly by `dashboard.destroy` (production never sets `PRAGMA foreign_keys`, so the declared FK cascade alone would not fire on SQLite). An asset orphaned by replacing a box image lives until then; a cleanup pass can come later if it ever matters. - Optional **night variant**: a second image auto-swapped by sun state — **deferred**, one additive field when it comes, no schema impact. +## E2. New box type: `thermostat` + +A circular-gauge widget for the thermostat integration (`docs/specs/thermostat.md`): current temperature and humidity, target setpoint, a preset bar and a drag-to-set dial. + +- New `DASHBOARD_BOX_TYPE.THERMOSTAT = 'thermostat'` in `server/utils/constants.js`. It is **not** a stretching box (absent from `TILE_STRETCH_BOX_TYPES` in `front/src/utils/dashboardSections.js`): unlike a value tile, the card is a stack of a dial, a preset bar and a status banner, so absorbing a column's leftover height would only pad the gauge with empty glass rather than make anything more readable. +- Box config — deliberately **one device-referencing key and nothing else**: + +| Key | Meaning | +|---|---| +| `thermostat_feature` | selector of the `thermostat` / `target-temperature` feature to display | +| `name` | optional card title override (shared with every other box type) | + +- **Why the config is this small.** Regulation settings — the sensor, the switch, the active schedule, the six preset temperatures, hysteresis, TPI cycle and band — are **device params**, never box fields. Putting them in `t_dashboard.boxes` would make a per-user dashboard document the source of truth of a control loop that turns real heaters on and off: the loop would have to read every dashboard on each tick, including the **private** dashboards of other users, and the same thermostat displayed on two dashboards with different settings would resolve non-deterministically. The widget chooses *which* thermostat to display; it never owns the regulation. +- `thermostat_feature` is a device-referencing field: it is listed in `FEATURE_STRING_FIELDS` (`server/lib/device/device.migrate.js`) and in `docs/specs/device-migration.md` B.3, so migrating the thermostat device rewrites the widget's selector. +- Values update live over the device websocket plus the `THERMOSTAT.*` messages (`PRESET_UPDATED`, `MANUAL_MODE_UPDATED`, `CONFIG_UPDATED`) the service broadcasts. + ## F. AI illustration generation through Gladys Plus Generating the `house-view` illustration is the one step that cannot be beautiful-by-default from a form alone. Gladys Plus already proxies AI calls; illustration generation follows the exact same pattern. diff --git a/docs/specs/device-migration.md b/docs/specs/device-migration.md index 53e2f7cf50..164f19a2a2 100644 --- a/docs/specs/device-migration.md +++ b/docs/specs/device-migration.md @@ -81,7 +81,7 @@ Two replacement maps are built once: `featureReplacements` (mapped source featur Fields rewritten — this list is **exhaustive and must stay in sync with the Joi schemas** of `server/models/scene.js` and `server/models/dashboard.js` (both reject unknown keys, so any new device-referencing field lands here in the same diff): - **Scene actions** (`t_scene.actions`, array of arrays, recursing into `condition.if-then-else`'s `if` / `then` / `else`): `device_feature` (feature), `device_features[]` (features), `device` (device), `devices[]` (devices), `camera` (device). - **Scene triggers** (`t_scene.triggers`, flat array): `device_feature` (feature), `device_features[]` (features), `device` (device — schema-declared legacy field, rewritten for safety). -- **Dashboard boxes** (`t_dashboard.boxes`, array of *sections* `{ columns: [[box]] }` since the flexible layout — legacy arrays of arrays are still walked): `device_feature` (feature), `device_features[]` (features), `device` (device), `camera` (device); plus the nested selector holders introduced by the wall-panel widgets — `chips[].device_feature` (feature), `pins[].device_feature` (feature), `actions[].device_feature` (feature, quick-actions box), and the **values** of `scene_status_features` (scene selector → feature selector map; keys are scene selectors and are not rewritten). Values are replaced **in place**; array length and order never change, keeping `device_feature_names` / `units` / `colors` index-aligned. +- **Dashboard boxes** (`t_dashboard.boxes`, array of *sections* `{ columns: [[box]] }` since the flexible layout — legacy arrays of arrays are still walked): `device_feature` (feature), `device_features[]` (features), `device` (device), `camera` (device); plus the nested selector holders introduced by the wall-panel widgets — `chips[].device_feature` (feature), `pins[].device_feature` (feature), `actions[].device_feature` (feature, quick-actions box), `thermostat_feature` (feature, thermostat box — the only device-referencing key that box carries, every regulation setting living on the device instead), and the **values** of `scene_status_features` (scene selector → feature selector map; keys are scene selectors and are not rewritten). Values are replaced **in place**; array length and order never change, keeping `device_feature_names` / `units` / `colors` index-aligned. Only scenes/dashboards that actually changed are saved. Rewritten scenes go through `SceneManager.addScene` so the RAM copy (`this.scenes`, the one `checkTrigger` iterates) and its scheduled triggers are replaced atomically with the DB copy — the same path as `scene.update`. Dashboards have no RAM cache. References to **unmapped** source features are intentionally left dangling (existing deletion semantics; the UI warned). diff --git a/docs/specs/thermostat.md b/docs/specs/thermostat.md new file mode 100644 index 0000000000..665bb32791 --- /dev/null +++ b/docs/specs/thermostat.md @@ -0,0 +1,235 @@ +# Thermostat: weekly schedules, virtual or on a real thermostat + +## Context + +Gladys can already *read and command* real thermostats (Netatmo, Matter, Zigbee, Z-Wave) through the `thermostat` device feature category. Two things are missing, and this integration fills both. + +**It cannot *be* the thermostat.** Turning a plain temperature sensor plus a plain switch — a relay, a smart plug, a boiler contact — into a regulated heating zone with a weekly programme is the most common French setup: an electric or hydronic heater driven by a contact, a separate sensor in the room, no branded thermostat anywhere. Today the answer is a hand-written scene per temperature threshold, with no schedule, no hysteresis and no anti-short-cycling. + +**It cannot *programme* the thermostats it already reads.** A Netatmo or a Zigbee thermostat regulates itself perfectly well, but nothing in Gladys drives its setpoint on a weekly schedule: the programme lives in the vendor's app, out of reach of scenes and of the rest of the house. + +The two needs share everything except the last step. The schedule, the presets, the manual override, the widget and the window handling are the same; only the final act differs — Gladys either actuates a switch itself, or writes a setpoint onto a device that actuates its own. Hence one integration with **two device types**, `virtual` and `external`, and a single code path that branches once, at the end of the regulation pass. + +## A. Device model + +One device per heating zone, created by the integration. `THERMOSTAT_TYPE` says which of the two kinds it is; a device saved before the param existed has none, and is a virtual one — so **no migration is needed**. + +### A.0 Virtual and external + +A **virtual** thermostat carries exactly **one** feature, because Gladys *is* the thermostat and the setpoint has to live somewhere: + +| | | +|---|---| +| Category | `DEVICE_FEATURE_CATEGORIES.THERMOSTAT` | +| Type | `DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE` | +| Unit | `celsius` or `fahrenheit` | + +No new category or type is introduced: a virtual thermostat is a thermostat, and it must be indistinguishable from a Netatmo one to the rest of Gladys (scenes, MQTT, Gladys Plus, the device pages). + +The feature is resolved **by category and type**, never by `device.features[0]`: feature order is not a contract, and a later added feature (mode, operating state) would otherwise silently retarget the regulation loop. + +An **external** thermostat carries **no feature at all**. Its setpoint is a feature of the real device — a Netatmo, a Zigbee TRV, a Matter thermostat, an MQTT climate entity — named by `THERMOSTAT_TARGET_FEATURE`. Creating a mirror feature here would give the house two setpoints that drift apart, and the whole point is that the real device stays the authority on its own state. Everything that keys on a selector (the runtime variables, the widget's `thermostat_feature`, the `/setpoint/` route) therefore keys on that **external** selector, which is why the rest of the integration needed no second code path. + +### A.0.1 What real thermostats actually expose + +The design is constrained by what integrations publish today, which is much less than the core defines: + +| Integration | `target-temperature` | `operating-state` | `mode` | +|---|---|---|---| +| Netatmo | yes | no — a boiler contact as `switch`/`binary` | no | +| Zigbee2MQTT | yes, **up to five** (heating/cooling, occupied/unoccupied) | no | no | +| Matter | yes, **two** (heating, cooling) | no | no | +| MQTT / Home Assistant | yes, when the discovery declares it | no | no | + +Three consequences, each of which is a rule the code follows: + +- **No auto-discovery of the target.** A Matter or Zigbee device exposes several setpoints and only the user knows which one drives their heating, so the three selectors are picked by hand in the edit form, out of *every* device in the house. +- **The state feature accepts two shapes.** `thermostat`/`operating-state` (0 idle / 1 heating / 2 cooling) *and* a read-only `switch`/`binary` boiler contact. Accepting only the standard type would leave every thermostat available today with no heating indication at all. It also accepts **nothing**: the widget then shows the setpoint without a heating halo. +- **The mode feature is optional.** No integration produces one. Requiring it would make the feature unusable for everybody. + +### A.0.2 No loops + +The feature pickers exclude devices owned by this service. Pointing an external thermostat at a virtual one would make Gladys write its own setpoint back to itself, once a minute, for ever. + +### A.1 Configuration lives on the device + +Everything the control loop needs is a `THERMOSTAT_*` device param: + +| Param | Meaning | +|---|---| +| `THERMOSTAT_TYPE` | `virtual` or `external`; absent means `virtual` | +| `THERMOSTAT_TEMPERATURE_FEATURE` | the sensor the loop regulates on (virtual); displayed only (external) | +| `THERMOSTAT_HUMIDITY_FEATURE` | optional, displayed only | +| `THERMOSTAT_SWITCH_FEATURE` | **virtual only** — the actuator the loop drives | +| `THERMOSTAT_TARGET_FEATURE` | **external only** — the real device's setpoint, written by the loop. Required | +| `THERMOSTAT_STATE_FEATURE` | **external only**, optional — `operating-state` or a binary boiler contact, read to show whether the equipment runs | +| `THERMOSTAT_MODE_FEATURE` | **external only**, optional — the real device's operating mode, if it has one | +| `THERMOSTAT_WINDOW_FEATURE` | optional opening sensor; cuts the switch when open (virtual) or writes the frost setpoint (external) — whatever the mode, so a running air conditioner is suspended like a heater | +| `THERMOSTAT_ACTIVE_SCHEDULE` | selector of the weekly schedule to follow, empty for none | +| `THERMOSTAT_MODE` | `heating` or `cooling` | +| `THERMOSTAT_CONTROL_TYPE` | `hysteresis` or `tpi` | +| `THERMOSTAT_MIN_TEMP` / `_MAX_TEMP` | bounds of the setpoint feature and of the widget dial | +| `THERMOSTAT_TEMP_UNIT` | `C` or `F` | +| `THERMOSTAT_MANUAL_DURATION` | how long a manual override holds, in minutes | +| `THERMOSTAT_PRESET_*` | the five preset setpoints (`off` has no setpoint) | +| `THERMOSTAT_HYSTERESIS_START` / `_STOP` | hysteresis band, in degrees of **difference** | +| `THERMOSTAT_TPI_CYCLE_TIME` / `_PROPORTIONAL_BAND` | TPI tuning, clamped by the regulation loop to 5-120 min and 0.5-10 degrees | + +`createDevice` accepts only this list plus, on a virtual thermostat, a single setpoint feature; anything else in the request body is dropped rather than persisted. Every field the edit form offers is in that list: a field the filter dropped would silently need a second store, which is exactly what this section forbids. + +On an external device `createDevice` **drops any feature sent alongside** and refuses a payload with no `THERMOSTAT_TARGET_FEATURE`: a thermostat with nothing to drive would sit in the integration page doing nothing, with no way to tell why. Switching a device back to `virtual` clears the three external params, so a stale selector can never keep driving a real thermostat. + +The hysteresis, TPI and switch params are meaningless on an external device — the real thermostat runs its own heuristic — and the edit form hides them there rather than offering settings that do nothing. + +The defaults for all of these live in `server/utils/thermostatConstants.js`, imported by the regulation loop, the widget and the edit form alike, so a device saved without a param is regulated exactly as the form displayed it. + +### A.2 Runtime state + +Per-thermostat *runtime* state — current preset, its non-off fallback, the manual override and its expiry — stays in `t_variable` under `THERMOSTAT__`, scoped to this **service id** rather than written globally, and removed by the service's `postDelete` hook when the device is deleted. The suffix list is shared between the write path and the cleanup, so a new suffix cannot be left behind. + +`` is derived from the thermostat's setpoint selector: this service's own feature on a virtual device, the `THERMOSTAT_TARGET_FEATURE` param on an external one, which owns no feature. The ownership check that guards these keys resolves both, or every preset and manual hold of an external thermostat would be refused as "not owned by this service" — and `postDelete` cleans up both, since the real device's feature survives the deletion and only the variables must go. + +Clients read and write it through `/api/v1/service/thermostat/state/:variable_key`, which accepts the runtime suffixes only. It is deliberately *not* mounted under `.../variable/...`: the core already mounts `/api/v1/service/:service_name/variable/:variable_key`, and that generic route would shadow it. Configuration keys are rejected there — the configuration lives on the device, and there is no `THERMOSTAT_CONFIG_*` variable any more. + +**Not on the dashboard.** See `docs/specs/dashboard-flexible-layout-and-widgets.md` E2: the widget carries `thermostat_feature` and nothing else. A control loop that actuates real heaters must not read its settings from a per-user dashboard document. + +## B. Presets, and why they are not `THERMOSTAT_MODE` + +The integration exposes six presets: `off`, `frost`, `away`, `eco`, `night`, `comfort`. + +These are **not** a competing spelling of the existing `THERMOSTAT_MODE` enum (`off` / `heating` / `cooling` / `auto`) that Matter, Zigbee and Z-Wave map onto. The two answer different questions: + +- `THERMOSTAT_MODE` says **what the machine does** — is it heating, cooling, or idle. It is a property of the equipment. +- A preset says **which temperature to aim for** — 7 °C frost protection, 16 °C away, 21 °C comfort. It is a property of the schedule. + +They compose rather than compete: a thermostat in `heating` mode follows a weekly programme whose 07:00 slot is `comfort`. This is the Netatmo/Tado vocabulary, and the vocabulary French heating programmers have used for decades (*confort / éco / hors-gel*), which is what makes a weekly schedule expressible at all — "heating" is not something you can put in a time slot. + +The presets are stored as a device-scoped variable and as the `preset` column of a schedule slot; they are **not** exposed as a device feature. A scene that wants a specific temperature sets the setpoint (section D); mapping presets onto a standard feature category can be added later without changing this model. + +> Open question for maintainers: whether a future `thermostat` / `preset` feature type should exist Gladys-wide, so branded integrations with the same notion (Netatmo, Tado, Overkiz) expose it uniformly. Out of scope here. + +## C. Regulation loop + +A single `setInterval` in the service ticks every 60 s and calls `applySchedules`, which regulates every thermostat device in parallel and isolates a failing device from the others. + +**Order of decisions**, per device. Steps 1 to 3 are identical for both device types — that is the whole point of the design; only steps 4 and 5 differ. + +1. **Window open** — if a window sensor is configured and reads `0`, the pass stops after suspending the heating: the switch is cut (virtual), or the device is stopped (external — frost-protection setpoint, plus `OFF` on its mode feature when it has one). A `NEW_STATE` listener applies the same cut immediately, without waiting for the next tick, using device params only (no dashboard read). +2. **Manual override** — if `THERMOSTAT_*_MANUAL_MODE` is `true` and its `_MANUAL_UNTIL` has not passed, the loop regulates on the manual setpoint. On expiry it clears the flag, broadcasts `MANUAL_MODE_UPDATED` and falls through to the schedule. +3. **Target preset** — the active schedule's slot for the current day and minute; failing that, the current preset variable; failing that, nothing is regulated. +4. **Setpoint** — on a virtual thermostat, saved on this service's own feature when it changed. On an external one, **written onto the real device** through the core, which routes it to the owning integration, preceded by the **mode** when the device exposes one (section C.0). +5. **Switch** — **virtual only**, actuated only when its state differs from the computed one. + +### C.0 An external thermostat stops at step 4 + +There is no step 5, and no hysteresis or TPI computation at all: the real thermostat runs its own heuristic off the setpoint it was given, and a second control loop would fight it. This is the entire difference between the two types. + +`off` has no setpoint of its own. On an external device it is expressed two ways at once, and which one the thermostat actually obeys depends on the device: + +- the **frost-protection setpoint**, always written — the only way to say "stop heating" that every thermostat understands, and the value it falls back on; +- **`THERMOSTAT_MODE.OFF` on `THERMOSTAT_MODE_FEATURE`**, when the real device exposes a mode. The setpoint alone leaves such a thermostat in `heating`: it stops aiming at 21 °C, but it fires again as soon as the room drops below 7 °C, and its own screen still reads "heating". Only the mode is a real stop. + +The mode is handed back to `heating` (or `cooling`, from `THERMOSTAT_MODE`) as soon as a heating preset takes over, and before the setpoint is written — a device still switched off would take the new setpoint and do nothing with it. The same applies to a manual setpoint, whether it comes from the widget dial, a scene or the device API. Like the setpoint, a mode write is skipped when the device already carries it, and clamped to the feature's `max`: a heating-only thermostat declares `max = 1` and would reject `COOLING`. + +Three properties of that write, each one required by a different vendor: + +- **Converted into the target feature's unit.** A thermostat configured in celsius pointing at a fahrenheit device would otherwise write `21` where the device reads 21 °F. MQTT/Home Assistant takes its unit from the discovery payload, so the mismatch is reachable in practice. +- **Clamped to the feature's own `min`/`max`.** Netatmo advertises 5-30, Zigbee 5-40, Matter -100-200. A value outside the range is rejected or silently clamped by the integration; clamping here is where it can be logged. +- **Skipped when the value already matches.** Several of these integrations call a cloud API on every write, and re-sending an unchanged setpoint once a minute would burn the rate limit for nothing. + +A write that fails — an unreachable integration, an expired cloud token, an external integration that does not acknowledge the command within the core's 5 s budget — is logged and swallowed: one dead thermostat must not stop the others, and the next tick retries it anyway since the value still differs. + +### C.0.1 The device is a second writer, and wins + +A real thermostat has its own dial, its vendor app and often its own internal programme: Gladys is **not** the only thing that writes its setpoint. Left alone, the loop would re-apply the stored preset on the next tick and silently undo whatever was set on the device — once a minute, for ever. From the outside this reads as an unstable thermostat: a value set on the vendor app reverts a moment later, sometimes before the app has even finished sending it. + +A setpoint change observed on a driven thermostat is therefore held exactly like a turn of the widget dial: `MANUAL_MODE` is armed, and the loop stops imposing the schedule. With a schedule the hold lasts `THERMOSTAT_MANUAL_DURATION` and the programme then resumes; without one it is permanent, like on a physical thermostat (section D). + +**Telling our own write apart from a real one.** Gladys's own write comes back as the same `NEW_STATE` event. Taken at face value it would arm a manual hold on *every scheduled write*, so the schedule would suspend itself the moment it applied. Each external write is therefore marked by selector before being sent, and the single echo matching that mark is consumed and ignored; anything else is a genuine change made on the device. + +This applies to external thermostats only. A virtual one has no second writer — Gladys owns its setpoint feature — so its own writes must never arm a hold. + +The selectors driven by this service are cached and rejected cheaply, in the same pass that builds the window-sensor set: `NEW_STATE` fires for every feature in the house, and neither check may cost a query. + +### C.0.2 The setpoint must be written on the owning device + +`gladys.device.setValue` routes on `device.service.name`. An external write therefore has to be handed **the device that owns the feature**, resolved from the selector — never this service's own thermostat device, which would route the write straight back into this integration's `setValue` and loop for ever. + +### C.1 Timezone + +Schedules are wall-clock times **in the house**. `getCurrentDayAndMinutes` therefore reads the day and minute in the timezone from `SYSTEM_VARIABLE_NAMES.TIMEZONE` (default `Europe/Paris`), like scenes, DuckDB and the energy jobs do — the official Docker image runs in UTC, so relying on the process timezone would fire a 07:00 comfort slot at 08:00 or 09:00 in France. + +The helper lives in `server/utils/thermostatSchedule.js`, imported by both the service and the widget so the two agree on the active slot — the schedule editor's slot algebra (`applySlotToDay`, `mergeIntoSlots`) comes from the same module rather than a second copy. It is deliberately in `utils/` and not in the service directory: the frontend build only aliases `server/utils/*`, and a service module is free to `require('../models')`, which would break the Vite build. + +The widget passes that timezone explicitly, read once from `SYSTEM_VARIABLE_NAMES.TIMEZONE`. Letting it default to the browser's would make a phone abroad, or a laptop left on another zone, display a slot other than the one actually heating the house. + +### C.2 Hysteresis and TPI + +**Virtual thermostats only** — see C.0. + +- **Hysteresis** (default): heat below `setpoint - hysteresis_start`, stop above `setpoint + hysteresis_stop`, hold the current state in between. Both values are temperature **differences**, so converting a thermostat to Fahrenheit scales them by 9/5 with **no** 32° offset — the absolute-temperature conversion would turn a 0.5 °C hysteresis into 32.9 °F. +- **TPI**: the switch is on for a fraction of each cycle proportional to the error within the proportional band. Heating only — a cooling compressor cannot be pulsed that way, so cooling always falls back to hysteresis. An on-time below one minute is rounded down to off: the regulation step is one minute, and a shorter pulse is both useless and hard on the relay. +- **TPI phase.** The position inside the cycle is offset by a hash of the thermostat's feature selector. Without it, every thermostat sharing a cycle time switches on at the same wall-clock minute, stacking the loads. + +### C.3 Sensor unit vs thermostat unit + +This section is about the *reading*, and applies to a virtual thermostat, which compares it to the setpoint. On an external one the same conversion is applied for **display**, and the symmetrical conversion is applied to the setpoint on the way out (C.0). + +The room sensor is a **separate device** from the thermostat, so nothing forces the two to share a unit: a Zigbee or Z-Wave probe reporting celsius next to a thermostat set to `THERMOSTAT_TEMP_UNIT = F` is a configuration the edit form allows. Comparing the raw reading to the setpoint would then put 68 against 20 and leave the heating permanently off — or, in cooling, permanently on. + +The reading is therefore converted into the thermostat's unit before any comparison, from the sensor's declared `feature.unit`. A sensor with **no** declared unit is assumed to already be in the thermostat's unit: that is the pre-existing behaviour, and guessing would be worse than not converting. + +The widget does the same on its side, and for the same reason — it renders the reading with the thermostat's unit symbol. The sensor unit is read once from the initial `GET /api/v1/device`; websocket `NEW_STATE` payloads do not carry it, so the value cached from that first read is what later events are converted with. + +## D. Scenes + +`setValue` is the path taken by `device.set-value` and by the generic device API. Persisting the value alone would not survive: the next regulation pass re-applies the scheduled preset and overwrites it within a minute, so a scene setting 21 °C would either do nothing useful or fight the loop every minute. + +A write coming from outside the loop is therefore treated as a **manual override**, exactly like turning the dial on the widget: the setpoint is saved, the manual flag is set, `MANUAL_MODE_UPDATED` is broadcast and a regulation pass is triggered. + +On an external thermostat the setpoint is not merely saved: it is written onto the real device through the core, on the device that owns it (C.0.2). Persisting it locally would refresh every Gladys screen while the thermostat itself never heard about it — the value would only reach it on the next regulation tick, up to a minute later. + +The **expiry is only armed when the device follows a schedule** — that is the only case where something would otherwise take the setpoint over. With a schedule, the setpoint holds for the device's `THERMOSTAT_MANUAL_DURATION` (30 minutes by default), then the schedule takes over again; the widget's countdown reads the same param, so what it displays is what the server enforces. Without a schedule the hold is **permanent**, like on a physical thermostat: arming a timer there would silently revert to the stored preset a few minutes later, and the widget only renders a countdown banner for a scheduled thermostat, so nothing would announce it. + +`POST /api/v1/service/thermostat/setpoint/:feature_selector` goes through the same `setValue`, and only after an ownership check — otherwise any authenticated household member could persist a value on a lock, a cover or a light just by naming its selector. The check has two arms, because the two device types own their setpoint differently: + +- **virtual**: the named feature is a `thermostat` / `target-temperature` feature **of one of this service's devices**; +- **external**: the named selector is the **`THERMOSTAT_TARGET_FEATURE` of one of this service's devices**. The feature belongs to another integration, so it cannot be matched by ownership — but it can only be reached if a user deliberately wired it to a thermostat on the integration page. + +The guard stays exactly as narrow in both cases: an arbitrary selector matches nothing. + +That route accepts an optional `manual` flag, default `true`. The widget passes `manual: false` in exactly two places: when a hold ends and the schedule takes the thermostat back, and when a preset is picked on a thermostat that follows no schedule. Both write the setpoint the loop is *already* going to regulate on, right after saving `MANUAL_MODE = false` — so treating them as overrides would re-arm the very flag they just cleared. The widget would keep showing the schedule while the database said manual, and a page refresh (which restores its state from the database) would come back in manual mode, until the expiry silently dropped it minutes later. Every other caller — scenes, the generic device API, the dial, the +/− buttons — means a manual override and gets the default. + +## E. Weekly schedules + +Two tables (migration `20260823000000`): + +- `t_thermostat_schedule`: `id`, `name`, `selector`. +- `t_thermostat_schedule_slot`: `schedule_id` (FK, `ON DELETE CASCADE`), `day_of_week` (0 = Monday … 6 = Sunday), `start_time` / `end_time` in `HH:MM`, `preset`. + +Slots are validated by Joi before reaching the database (`day_of_week` 0–6, `HH:MM` pattern, preset enum) and by the model itself. An invalid slot would otherwise be stored and then silently match nothing at regulation time. + +A slot ending at `00:00` means end of day. A slot whose end is before its start crosses midnight and is matched in two halves — the start day's evening, then the following day's small hours — which is what makes a single "22:00 → 06:00 night" slot expressible. + +Deleting a schedule first **detaches** the thermostats that follow it, dropping their `THERMOSTAT_ACTIVE_SCHEDULE` param. The regulation degrades gracefully on a missing schedule — it falls back on the stored preset — but the device would otherwise keep an orphan reference the edit page cannot resolve, and which a new schedule reusing the selector would silently inherit. The slots themselves go with the schedule through the foreign key's `ON DELETE CASCADE`. + +## F. The widget, on an external thermostat + +The dashboard widget is the same in both cases; three details differ. + +**It targets the real device's selector.** `thermostat_feature` holds the external selector, so the picker — which lists this service's devices — offers an external thermostat under its own name, resolved through `THERMOSTAT_TARGET_FEATURE`. Without that it would have no feature to show and could never be added to a dashboard. + +**The heating halo reads `THERMOSTAT_STATE_FEATURE`** instead of the switch, normalising the two accepted shapes (A.0.1). `NEW_STATE` payloads carry no category or type, so the shape is taken from the feature read at mount and the value normalised against it. With no state feature configured, the widget falls back to estimating the state from the setpoint, as it already did for a thermostat with no switch. + +**The real device is a second source of truth.** A setpoint changed on the thermostat itself — its own dial, the vendor app, its internal programme — arrives as a `NEW_STATE` and is displayed, where a virtual thermostat holds its local manual setpoint instead. Only Gladys writes a virtual setpoint, so there is nothing to follow there; on an external one, holding it would leave the widget showing a value the thermostat no longer has. The short-lived hold that protects the user's own in-flight write (`expectedSetpoint`) still applies, so the dial does not jump while it is being turned. + +**A hold taken on the device un-highlights the preset.** The preset bar shows which preset the setpoint comes from, and a value set on the thermostat itself no longer comes from one. The widget already dropped the highlight for a setpoint set on its own dial (`manualSetpointOverride`, which deliberately ignores holds armed by scenes); on an external thermostat a manual hold *always* means the setpoint left the preset, whether it was armed here or on the device. This applies on the live event and on a page reload, which restores the same state from the database. Picking a preset clears the hold and lights it back up. + +The dial bounds come from the target feature's own `min`/`max` when it declares them, and fall back to `THERMOSTAT_MIN_TEMP` / `_MAX_TEMP` otherwise — the device knows its range better than the form does. + +## G. Out of scope + +- Fil pilote heaters (`heater` / `pilot-wire-mode`): the actuator picker is `switch` / `binary` only. Additive when it comes. +- Presets as a Gladys-wide device feature type (section B). +- Multi-zone grouping, holiday mode, open-window *detection* by temperature drop (as opposed to a sensor). +- **Driving a second setpoint on an external thermostat.** A reversible Matter or Zigbee device exposes a heating *and* a cooling setpoint; one `THERMOSTAT_TARGET_FEATURE` is written, and a house wanting both creates two thermostats. Additive. diff --git a/front/src/assets/integrations/cover/thermostat.jpg b/front/src/assets/integrations/cover/thermostat.jpg new file mode 100644 index 0000000000..87d3d49dea Binary files /dev/null and b/front/src/assets/integrations/cover/thermostat.jpg differ diff --git a/front/src/components/app.jsx b/front/src/components/app.jsx index 4982905f34..29b86be9ab 100644 --- a/front/src/components/app.jsx +++ b/front/src/components/app.jsx @@ -205,6 +205,10 @@ import ExternalIntegrationSupervisionPage from '../routes/integration/all/extern import ExternalIntegrationLogsPage from '../routes/integration/all/external-integration/logs-page'; import ExternalIntegrationInstallPage from '../routes/integration/all/external-integration/install-page'; import ExternalIntegrationOAuthCallbackPage from '../routes/integration/all/external-integration/oauth-callback-page'; +// Thermostat integration +import ThermostatDevicePage from '../routes/integration/all/thermostat/device-page'; +import ThermostatEditPage from '../routes/integration/all/thermostat/edit-page'; +import ThermostatSchedulePage from '../routes/integration/all/thermostat/schedule-page'; const defaultState = getDefaultState(); const store = createStore(defaultState); @@ -393,6 +397,11 @@ const AppRouter = connect( + + + + + diff --git a/front/src/components/boxs/device-in-room/device-features/style.css b/front/src/components/boxs/device-in-room/device-features/style.css index 4a27efcbcd..7f09f6fbb6 100644 --- a/front/src/components/boxs/device-in-room/device-features/style.css +++ b/front/src/components/boxs/device-in-room/device-features/style.css @@ -23,12 +23,14 @@ input[type='range'][class~='light-temperature']::-ms-fill-lower { .removeNumberArrow::-webkit-outer-spin-button, .removeNumberArrow::-webkit-inner-spin-button { -webkit-appearance: none; + appearance: none; margin: 0; } /* Firefox */ .removeNumberArrow { -moz-appearance: textfield; + appearance: textfield; } .setpointHorizontalControls { diff --git a/front/src/components/boxs/thermostat/CircularGauge.jsx b/front/src/components/boxs/thermostat/CircularGauge.jsx new file mode 100644 index 0000000000..6e5cf44b99 --- /dev/null +++ b/front/src/components/boxs/thermostat/CircularGauge.jsx @@ -0,0 +1,301 @@ +import style from './style.css'; + +// The gauge is drawn as an SVG arc spanning ARC_DEGREES, opening at the bottom: +// it starts at ARC_START_ANGLE (150°, lower-left) and sweeps clockwise. +export const ARC_DEGREES = 240; +export const ARC_START_ANGLE = 150; + +// Feather/Lucide glyphs, by codepoint. Native emoji render differently on every +// OS — and at different sizes — where the rest of Gladys draws its icons from +// this font; SVG cannot use the `fe fe-*` classes, which work through a +// :before pseudo-element, so the codepoints are inlined here. +const ICONS = { + droplet: '\ue0b4', + flame: '\ue0d2', + snowflake: '\ue165' +}; + +/** + * Convert a polar coordinate (angle in degrees, 0 = 12 o'clock) to cartesian. + */ +function polarToCartesian(cx, cy, r, angleDeg) { + const rad = ((angleDeg - 90) * Math.PI) / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +function describeArc(cx, cy, r, startAngle, endAngle) { + const start = polarToCartesian(cx, cy, r, startAngle); + const end = polarToCartesian(cx, cy, r, endAngle); + const largeArc = endAngle - startAngle > 180 ? '1' : '0'; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`; +} + +const CircularGauge = ({ + setpoint, + currentTemp, + humidity, + onPointerDown, + onIncrement, + onDecrement, + minTemp, + maxTemp, + mode, + isActive, + isWindowOpen, + tempUnit, + a11yLabels = {} +}) => { + const cx = 110; + const cy = 110; + const r = 88; + const sw = 11; + const range = maxTemp - minTemp; + const pct = range === 0 ? 0.5 : Math.min(1, Math.max(0, (setpoint - minTemp) / range)); + const arcEnd = ARC_START_ANGLE + Math.max(pct, 0.001) * ARC_DEGREES; + const bgPath = describeArc(cx, cy, r, ARC_START_ANGLE, ARC_START_ANGLE + ARC_DEGREES); + const fgPath = describeArc(cx, cy, r, ARC_START_ANGLE, arcEnd); + const knob = polarToCartesian(cx, cy, r, arcEnd); + // An open window suspends the heating, so the arc goes grey like the off mode: + // leaving it orange showed a thermostat calling for heat while the switch was + // being held off, which is the one thing the gauge must not misreport. + const baseArcColor = mode === 'cooling' ? '#3b82f6' : mode === 'off' ? '#adb5bd' : '#f97316'; + const arcColor = isWindowOpen ? '#adb5bd' : baseArcColor; + // Derive both halves from one rounded value: splitting the raw setpoint made + // 20.96 render as "20.10" (the decimal carried to 10) and -3.5 as "-4.5" + // (floor rounds away from zero for negatives). + const roundedSetpoint = Math.round(setpoint * 10) / 10; + const truncated = Math.trunc(roundedSetpoint); + const decPart = Math.round(Math.abs(roundedSetpoint - truncated) * 10); + // Math.trunc(-0.5) is -0, which renders as "0": a setpoint between -1 and 0 + // would lose its sign, so the minus is restored explicitly. + const intPart = truncated === 0 && roundedSetpoint < 0 ? '-0' : String(truncated); + const intW = intPart.length * 30; + const intX = cx - intW / 2 - 18; + const suffixX = intX + intW; + + const hasCurrentTemp = currentTemp !== null && currentTemp !== undefined; + const hasHumidity = humidity !== null && humidity !== undefined; + + // A stopped thermostat has no setpoint to show. The number the gauge would + // otherwise draw is the frost-protection fallback the server writes to stop a + // real device — 7 °C under a lit Off button reads as "it is aiming at 7 °C", + // which is the opposite of what the user asked for. The word takes its place, + // and the arc keeps showing where that fallback sits. + const isOff = mode === 'off'; + const offLabel = a11yLabels.off || 'Off'; + + // One sentence for a screen reader, instead of the raw SVG texts being read + // one fragment at a time ("21", ".0", "\u00b0", "C"). The individual + // nodes are hidden from the tree for the same reason. + const unit = `\u00b0${tempUnit || 'C'}`; + const label = [ + isOff ? offLabel : `${a11yLabels.setpoint || 'Setpoint'} ${roundedSetpoint} ${unit}`, + hasCurrentTemp + ? `${a11yLabels.currentTemp || 'Current temperature'} ${Number(currentTemp).toFixed(1)} ${unit}` + : null, + hasHumidity ? `${a11yLabels.humidity || 'Humidity'} ${Math.round(humidity)} %` : null, + isWindowOpen ? a11yLabels.windowOpen || null : null + ] + .filter(Boolean) + .join(', '); + + // Arrow keys move the setpoint, which is what role="slider" promises. Without + // this the dial is the only way to set a temperature, and a dial cannot be + // operated from a keyboard at all. + const onKeyDown = event => { + if (!onIncrement && !onDecrement) { + return; + } + if (event.key === 'ArrowUp' || event.key === 'ArrowRight') { + event.preventDefault(); + if (onIncrement) onIncrement(); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') { + event.preventDefault(); + if (onDecrement) onDecrement(); + } + }; + + const interactive = !!(onIncrement || onDecrement || onPointerDown); + + return ( + + + {/* The glow marks "running right now", which is just as true of a running + air conditioner as of a running heater, so it applies in both modes. + It is a drop-shadow rather than a feGaussianBlur/feMerge filter: merging + a blurred copy under the stroke softens the stroke's own edges, which on + this pale background turned the blue arc into a grey smear. A shadow + leaves the stroke untouched and only casts colour around it. */} + + + + {/* Current temp + humidity: above setpoint */} + {hasCurrentTemp && ( + + )} + {hasHumidity && ( + + )} + + {/* Setpoint: integer + decimal + unit split (° above dot, C above decimal), + replaced by the word "off" when the thermostat is stopped. */} + {isOff ? ( + + ) : ( + + + + + + + )} + + {/* Active icon: at bottom of gauge */} + {/* No icon for an open window: the icon font has no window glyph, and the + state is already named by the banner under the gauge. */} + {!isWindowOpen && isActive && mode === 'heating' && ( + + )} + {!isWindowOpen && isActive && mode === 'cooling' && ( + + )} + + {/* The arrow keys on the slider cover the keyboard case, so these stay + out of the tab order and out of the accessibility tree: exposing them + as two more controls would make a reader announce three ways to change + one value. They keep their pointer behaviour. */} + {onIncrement && ( + e.stopPropagation()} class={style.arcBtnGroup} aria-hidden="true"> + {/* Transparent and wider than the visible circle: the button keeps + its 30px look while the tap area reaches the recommended size. */} + + + + + )} + {onDecrement && ( + e.stopPropagation()} class={style.arcBtnGroup} aria-hidden="true"> + + + + + )} + + ); +}; + +export default CircularGauge; diff --git a/front/src/components/boxs/thermostat/EditThermostatBox.jsx b/front/src/components/boxs/thermostat/EditThermostatBox.jsx new file mode 100644 index 0000000000..131411d52c --- /dev/null +++ b/front/src/components/boxs/thermostat/EditThermostatBox.jsx @@ -0,0 +1,124 @@ +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import { connect } from 'unistore/preact'; +import Select from 'react-select'; +import { getDeviceFeatureName } from '../../../utils/device'; +import withIntlAsProp from '../../../utils/withIntlAsProp'; +import BaseEditBox from '../baseEditBox'; +import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../server/utils/constants'; + +const SELECT_STYLES = { + valueContainer: provided => ({ ...provided, paddingLeft: '8px' }), + input: provided => ({ ...provided, paddingLeft: '4px' }), + placeholder: provided => ({ ...provided, paddingLeft: '4px' }), + singleValue: provided => ({ ...provided, marginLeft: '0px', paddingLeft: '4px' }) +}; + +class EditThermostatBoxComponent extends Component { + updateName = e => { + this.props.updateBoxConfig(this.props.x, this.props.y, { name: e.target.value || undefined }); + }; + + updateThermostatFeature = option => { + this.props.updateBoxConfig(this.props.x, this.props.y, { thermostat_feature: option ? option.value : null }); + this.setState({ selectedThermostatOption: option || null }); + }; + + buildOptions = devices => { + const options = []; + devices.forEach(device => { + const featureOptions = []; + device.features.forEach(feature => { + // Only the setpoints created by this integration: the widget drives the + // thermostat service, which cannot regulate a feature it does not own. + if ( + feature.category !== DEVICE_FEATURE_CATEGORIES.THERMOSTAT || + feature.type !== DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE + ) { + return; + } + featureOptions.push({ + value: feature.selector, + label: getDeviceFeatureName(this.props.intl.dictionary, device, feature) + }); + }); + // An external thermostat owns no feature: the setpoint it drives belongs + // to the real device (Netatmo, Zigbee, Matter, MQTT...). It is still one of + // this service's thermostats, so it is offered under its own name — without + // this it could never be added to a dashboard at all. + const targetParam = (device.params || []).find(param => param.name === 'THERMOSTAT_TARGET_FEATURE'); + if (featureOptions.length === 0 && targetParam && targetParam.value) { + featureOptions.push({ value: targetParam.value, label: device.name }); + } + if (featureOptions.length > 0) { + options.push({ label: device.name, options: featureOptions }); + } + }); + return options; + }; + + getDevices = async () => { + try { + const devices = await this.props.httpClient.get('/api/v1/service/thermostat/device'); + const thermostatOptions = this.buildOptions(devices); + let selectedThermostatOption = null; + thermostatOptions.forEach(group => + group.options.forEach(opt => { + if (opt.value === this.props.box.thermostat_feature) selectedThermostatOption = opt; + }) + ); + this.setState({ thermostatOptions, selectedThermostatOption }); + } catch (e) { + this.setState({ thermostatOptions: [] }); + } + }; + + componentDidMount() { + this.getDevices(); + } + + render(props, { thermostatOptions, selectedThermostatOption }) { + const t = props.intl && props.intl.dictionary && props.intl.dictionary.dashboard.boxes.thermostat; + const placeholder = (t && t.selectPlaceholder) || ''; + + return ( + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + + + + {scheduleName || } + +
+
+ + + + + {setpoint === null ? ( + + ) : ( + `${setpoint} °${tempUnitValue}` + )} + +
+
+ + {confirmDelete ? ( + // The confirmation takes over the whole row: keeping Save and + // Edit alongside it would put four buttons in a col-md-6 card, + // where flex-fill shrinks them until the labels are cut off. +
+ + + +
+ + +
+
+ ) : ( +
+ + + + + +
+ )} + + + + + + ); + } +} + +export default ThermostatDeviceBox; diff --git a/front/src/routes/integration/all/thermostat/device-page/actions.js b/front/src/routes/integration/all/thermostat/device-page/actions.js new file mode 100644 index 0000000000..0c573077e1 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/device-page/actions.js @@ -0,0 +1,164 @@ +import { RequestStatus } from '../../../../../utils/consts'; +import update from 'immutability-helper'; +import debounce from 'debounce'; +import createActionsHouse from '../../../../../actions/house'; + +function createActions(store) { + const houseActions = createActionsHouse(store); + const actions = { + async getThermostatDevices(state) { + store.setState({ getThermostatDevicesStatus: RequestStatus.Getting }); + try { + const options = { + order_dir: state.getThermostatDeviceOrderDir || 'asc' + }; + if (state.thermostatDeviceSearch && state.thermostatDeviceSearch.length) { + options.search = state.thermostatDeviceSearch; + } + const allDevices = await state.httpClient.get('/api/v1/service/thermostat/device', options); + const filtered = Array.isArray(allDevices) ? allDevices : []; + // The active schedule is a device param, so it comes back with the device: + // no extra variable round-trip per thermostat. + const enriched = filtered.map(device => { + const getParam = name => { + const param = (device.params || []).find(p => p.name === name); + return (param && param.value) || ''; + }; + return { + ...device, + active_schedule: getParam('THERMOSTAT_ACTIVE_SCHEDULE'), + // An external thermostat owns no setpoint feature: the card has to + // read the one on the real device it drives, whose selector is the + // only trace of it the thermostat device carries. + thermostat_type: getParam('THERMOSTAT_TYPE') || 'virtual', + target_feature: getParam('THERMOSTAT_TARGET_FEATURE') + }; + }); + store.setState({ + thermostatDevices: await actions.withExternalSetpoints(state, enriched), + getThermostatDevicesStatus: RequestStatus.Success + }); + } catch (e) { + store.setState({ + thermostatDevices: [], + getThermostatDevicesStatus: RequestStatus.Error + }); + } + }, + /** + * Read back the setpoints of the external thermostats. + * + * They live on the real devices (a Netatmo, a Zigbee TRV...), which this + * service's own /device route never returns: without this the card shows + * "no setpoint" on every thermostat that has one. All of them are fetched + * in a single call, so the list costs one extra request whatever its size. + */ + async withExternalSetpoints(state, devices) { + const selectors = devices + .filter(device => device.thermostat_type === 'external' && device.target_feature) + .map(device => device.target_feature); + if (selectors.length === 0) { + return devices; + } + let bySelector = {}; + try { + const realDevices = await state.httpClient.get('/api/v1/device', { + device_feature_selectors: selectors.join(',') + }); + (realDevices || []).forEach(realDevice => { + (realDevice.features || []).forEach(feature => { + if (selectors.includes(feature.selector)) { + bySelector[feature.selector] = feature; + } + }); + }); + } catch (e) { + // The card falls back to "no setpoint": a thermostat that cannot be + // read is not a reason to fail the whole list. + bySelector = {}; + } + return devices.map(device => { + const feature = bySelector[device.target_feature]; + return feature ? { ...device, external_setpoint_feature: feature } : device; + }); + }, + async saveDevice(state, device, index) { + // Everything the list added for display only is stripped here: the device + // route validates its payload, and an unknown field makes the save fail. + const { active_schedule, thermostat_type, target_feature, external_setpoint_feature, ...deviceToSave } = device; + // Persist the schedule as a device param rather than a global variable. + const otherParams = (deviceToSave.params || []).filter(p => p.name !== 'THERMOSTAT_ACTIVE_SCHEDULE'); + deviceToSave.params = [...otherParams, { name: 'THERMOSTAT_ACTIVE_SCHEDULE', value: active_schedule || '' }]; + const savedDevice = await state.httpClient.post('/api/v1/device', deviceToSave); + // Read the schedule back from what the server actually stored rather than + // from the form value: the widget derives its banner from this param, and + // showing an unsaved value would make it disagree with the regulation. + const savedParam = (savedDevice.params || []).find(p => p.name === 'THERMOSTAT_ACTIVE_SCHEDULE'); + const savedSchedule = savedParam ? savedParam.value : active_schedule || ''; + const newState = update(state, { + thermostatDevices: { + $splice: [ + [ + index, + 1, + { + ...savedDevice, + active_schedule: savedSchedule, + // The save returns the thermostat device, never the real one it + // drives: the setpoint read alongside the list is carried over + // rather than dropped, which would blank the card after a save. + thermostat_type, + target_feature, + external_setpoint_feature + } + ] + ] + } + }); + store.setState(newState); + // Apply the new schedule now instead of waiting for the next minute tick. + try { + await state.httpClient.post('/api/v1/service/thermostat/apply-schedules', {}); + } catch (e) { + // The regulation loop picks it up within a minute anyway. + } + }, + updateDeviceProperty(state, index, property, value) { + const newState = update(state, { + thermostatDevices: { + [index]: { + [property]: { $set: value } + } + } + }); + store.setState(newState); + }, + async getSchedules(state) { + try { + const schedules = await state.httpClient.get('/api/v1/service/thermostat/schedule'); + store.setState({ thermostatSchedules: Array.isArray(schedules) ? schedules : [] }); + } catch (e) { + store.setState({ thermostatSchedules: [] }); + } + }, + async deleteDevice(state, device, index) { + await state.httpClient.delete(`/api/v1/device/${device.selector}`); + const newState = update(state, { + thermostatDevices: { $splice: [[index, 1]] } + }); + store.setState(newState); + }, + async search(state, e) { + await store.setState({ thermostatDeviceSearch: e.target.value }); + actions.debouncedGetThermostatDevices(store.getState()); + }, + async changeOrderDir(state, e) { + store.setState({ getThermostatDeviceOrderDir: e.target.value }); + await actions.getThermostatDevices(store.getState()); + } + }; + actions.debouncedGetThermostatDevices = debounce(actions.getThermostatDevices, 200); + return Object.assign({}, houseActions, actions); +} + +export default createActions; diff --git a/front/src/routes/integration/all/thermostat/device-page/index.js b/front/src/routes/integration/all/thermostat/device-page/index.js new file mode 100644 index 0000000000..1fd64d12f9 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/device-page/index.js @@ -0,0 +1,26 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from './actions'; +import ThermostatPage from '../ThermostatPage'; +import DeviceTab from './DeviceTab'; + +class ThermostatDevicePage extends Component { + componentWillMount() { + this.props.getThermostatDevices(); + this.props.getHouses(); + this.props.getSchedules(); + } + + render(props) { + return ( + + + + ); + } +} + +export default connect( + 'user,houses,thermostatDevices,getThermostatDevicesStatus,thermostatDeviceSearch,getThermostatDeviceOrderDir,thermostatSchedules', + actions +)(ThermostatDevicePage); diff --git a/front/src/routes/integration/all/thermostat/device-page/style.css b/front/src/routes/integration/all/thermostat/device-page/style.css new file mode 100644 index 0000000000..eebb306b3d --- /dev/null +++ b/front/src/routes/integration/all/thermostat/device-page/style.css @@ -0,0 +1,53 @@ +.buttonGroup { + display: flex; + gap: 8px; +} + +/* Wraps on a narrow card: the question goes on its own line and the two + answers keep a usable width instead of being squeezed beside it. */ +.confirmDeleteRow { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.confirmDeleteRow .buttonGroup { + flex: 1; + min-width: 160px; +} + +.confirmDeleteText { + font-weight: 500; +} + +/* Read-only summary of what the thermostat is set to, so the card answers the + two questions that used to require opening the edit page. */ +.summaryRow { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + margin-bottom: 16px; +} + +.summaryItem { + display: flex; + flex-direction: column; + min-width: 0; +} + +.summaryLabel { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #9aa0ac; +} + +.summaryValue { + font-size: 0.9rem; + font-weight: 500; + color: #495057; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/front/src/routes/integration/all/thermostat/edit-page/EditForm.jsx b/front/src/routes/integration/all/thermostat/edit-page/EditForm.jsx new file mode 100644 index 0000000000..ded508fe75 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/EditForm.jsx @@ -0,0 +1,643 @@ +import { Text, Localizer } from 'preact-i18n'; +import cx from 'classnames'; +import { RequestStatus } from '../../../../../utils/consts'; +import style from './style.css'; +import { getPresetColor } from '../../../../../utils/thermostatPresetColors'; + +const FeatureSelect = ({ value, features, onChange, emptyLabel }) => ( + +); + +const EditForm = ({ ...props }) => { + const saving = props.thermostatCreateStatus === RequestStatus.Getting; + const isEdit = !!(props.thermostatEditDevice && props.thermostatEditDevice.selector); + const mode = props.thermostatEditMode || 'heating'; + // The control rules read the other way round in cooling: the switch turns on + // when the room is too warm. The help texts are therefore per-mode, not a + // heating text with the word swapped. + const modeSuffix = mode === 'cooling' ? 'cooling' : 'heating'; + + const heatingPresets = ['frost', 'away', 'eco', 'night', 'comfort']; + const coolingPresets = ['comfort']; + const activePresets = mode === 'cooling' ? coolingPresets : heatingPresets; + + const presetFields = { + frost: 'thermostatEditPresetFrost', + away: 'thermostatEditPresetAway', + eco: 'thermostatEditPresetEco', + night: 'thermostatEditPresetNight', + comfort: 'thermostatEditPresetComfort' + }; + + const controlType = props.thermostatEditControlType || 'hysteresis'; + // A real thermostat runs its own heuristic and drives its own heater: Gladys + // only writes the setpoint its schedule resolves. Hysteresis, TPI and the + // switch are therefore hidden — offering them would suggest Gladys regulates + // a device that regulates itself. + const isExternalThermostat = props.thermostatEditType === 'external'; + + return ( +
+
+

+ {isEdit ? ( + + ) : ( + + )} +

+
+
+
+
+
+ {props.thermostatCreateStatus === RequestStatus.Error && ( +
+ +
+ )} + + {/* Nom */} +
+ + + } + value={props.thermostatEditName} + onInput={e => props.updateThermostatField('thermostatEditName', e.target.value)} + /> + +
+ + {/* Pièce */} +
+ + +
+ + {/* Type de thermostat */} +
+ + + + + +
+ + {/* Thermostat réel piloté */} + {isExternalThermostat && ( +
+
+ + + props.updateThermostatField('thermostatEditTargetFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ +
+ + + props.updateThermostatField('thermostatEditStateFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ +
+ + + props.updateThermostatField('thermostatEditModeFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+
+ )} + + {/* Mode */} +
+ + +
+ + {/* Type de calcul + paramètres associés. Un vrai thermostat gère sa + propre heuristique : Gladys ne lui envoie qu'une consigne. */} + {!isExternalThermostat && ( +
+
+ + + + {controlType === 'tpi' ? ( + + + + + {' — '} + + + ) : ( + + + + + {' — '} + + + )} + +
+ + {/* Paramètres hystérésis */} + {controlType === 'hysteresis' && ( +
+
+
+ +
+ props.updateThermostatField('thermostatEditHysteresisStart', e.target.value)} + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ + + +
+
+
+
+ +
+ props.updateThermostatField('thermostatEditHysteresisStop', e.target.value)} + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ + + +
+
+
+ )} + + {/* Paramètres TPI */} + {controlType === 'tpi' && ( +
+
+
+ +
+ props.updateThermostatField('thermostatEditTpiCycleTime', e.target.value)} + /> +
+ min +
+
+ + + +
+
+
+
+ +
+ + props.updateThermostatField('thermostatEditTpiProportionalBand', e.target.value) + } + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ + + +
+
+
+ )} +
+ )} + + {/* Unité + Plage de température */} +
+
+
+ + +
+
+
+
+ +
+ + } + value={props.thermostatEditMinTemp} + onInput={e => props.updateThermostatField('thermostatEditMinTemp', e.target.value)} + /> + +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+
+
+
+
+ +
+ + } + value={props.thermostatEditMaxTemp} + onInput={e => props.updateThermostatField('thermostatEditMaxTemp', e.target.value)} + /> + +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+
+
+
+ + {/* Capteur de température */} +
+ + + props.updateThermostatField('thermostatEditTemperatureFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ + {/* Capteur d'humidité */} +
+ + + props.updateThermostatField('thermostatEditHumidityFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ + {/* Commutateur : seul un thermostat virtuel pilote un interrupteur. */} + {!isExternalThermostat && ( +
+ + + props.updateThermostatField('thermostatEditSwitchFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ )} + + {/* Capteur d'ouverture de fenêtre */} +
+ + + props.updateThermostatField('thermostatEditWindowFeature', e.target.value)} + emptyLabel={} + /> + + + + +
+ + {/* Presets : nom + couleur fixe + température */} +
+ + + + + + + + + + {['off', ...activePresets].map(key => ( + + + + + ))} + +
+ + + +
+ + + + {presetFields[key] ? ( +
+ props.updateThermostatField(presetFields[key], e.target.value)} + step="0.5" + /> +
+ + {(props.thermostatEditTempUnit || 'C') === 'F' ? '°F' : '°C'} + +
+
+ ) : ( + + )} +
+
+ + {/* Planning actif */} +
+ + + + + +
+ + {/* Durée mode manuel */} +
+ +
+ props.updateThermostatField('thermostatEditManualDuration', e.target.value)} + step="1" + /> +
+ + + +
+
+ + + +
+ +
+
+ + + + +
+
+
+
+
+
+ ); +}; + +export default EditForm; diff --git a/front/src/routes/integration/all/thermostat/edit-page/actions.js b/front/src/routes/integration/all/thermostat/edit-page/actions.js new file mode 100644 index 0000000000..cfbc7e0ede --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/actions.js @@ -0,0 +1,337 @@ +import { RequestStatus } from '../../../../../utils/consts'; +import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } from '../../../../../../../server/utils/constants'; +import { route } from 'preact-router'; +import createActionsHouse from '../../../../../actions/house'; + +const TEMPERATURE_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR]; +const HUMIDITY_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR]; +const SWITCH_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.SWITCH]; +const OPENING_CATEGORIES = [DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR]; + +function createActions(store) { + const houseActions = createActionsHouse(store); + const actions = { + async getSchedules(state) { + try { + const schedules = await state.httpClient.get('/api/v1/service/thermostat/schedule'); + store.setState({ thermostatSchedules: schedules }); + } catch (e) { + store.setState({ thermostatSchedules: [] }); + } + }, + + async getDevicesForThermostatEdit(state) { + try { + const devices = await state.httpClient.get('/api/v1/device'); + const temperatureFeatures = []; + const humidityFeatures = []; + const switchFeatures = []; + const openingFeatures = []; + const targetFeatures = []; + const stateFeatures = []; + const modeFeatures = []; + devices.forEach(device => { + // A thermostat this integration created is not a real thermostat to + // drive: pointing an external thermostat at one would make Gladys + // write its own setpoint back to itself. + const isOwnThermostat = device.service && device.service.name === 'thermostat'; + device.features.forEach(feature => { + const entry = { selector: feature.selector, label: `${device.name} - ${feature.name}` }; + if (TEMPERATURE_CATEGORIES.includes(feature.category)) { + temperatureFeatures.push(entry); + } + if (HUMIDITY_CATEGORIES.includes(feature.category)) { + humidityFeatures.push(entry); + } + if (SWITCH_CATEGORIES.includes(feature.category) && feature.type === 'binary') { + switchFeatures.push(entry); + // No integration publishes thermostat/operating-state yet: Netatmo + // reports its boiler contact as a read-only switch, and that is the + // only "is it heating" signal most real thermostats expose. + if (!isOwnThermostat) { + stateFeatures.push(entry); + } + } + if (OPENING_CATEGORIES.includes(feature.category)) { + openingFeatures.push(entry); + } + if (!isOwnThermostat && feature.category === DEVICE_FEATURE_CATEGORIES.THERMOSTAT) { + if (feature.type === DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE && !feature.read_only) { + // Zigbee, Matter and MQTT expose several setpoints on one device + // (heating/cooling, occupied/unoccupied): only the user knows + // which one drives their heating, so all of them are offered. + targetFeatures.push(entry); + } + if (feature.type === DEVICE_FEATURE_TYPES.THERMOSTAT.OPERATING_STATE) { + stateFeatures.push(entry); + } + if (feature.type === DEVICE_FEATURE_TYPES.THERMOSTAT.MODE) { + modeFeatures.push(entry); + } + } + }); + }); + store.setState({ + temperatureFeatures, + humidityFeatures, + switchFeatures, + openingFeatures, + targetFeatures, + stateFeatures, + modeFeatures + }); + } catch (e) { + store.setState({ + temperatureFeatures: [], + humidityFeatures: [], + switchFeatures: [], + openingFeatures: [], + targetFeatures: [], + stateFeatures: [], + modeFeatures: [] + }); + } + }, + + async getThermostatDevice(state, selector) { + store.setState({ getThermostatDeviceStatus: RequestStatus.Getting }); + try { + const device = await state.httpClient.get(`/api/v1/device/${selector}`); + const getParam = name => { + const p = (device.params || []).find(x => x.name === name); + return p ? p.value : null; + }; + store.setState({ + thermostatEditDevice: device, + thermostatEditName: device.name, + thermostatEditMode: getParam('THERMOSTAT_MODE') || 'heating', + thermostatEditMinTemp: getParam('THERMOSTAT_MIN_TEMP') || '5', + thermostatEditMaxTemp: getParam('THERMOSTAT_MAX_TEMP') || '35', + thermostatEditTempUnit: getParam('THERMOSTAT_TEMP_UNIT') || 'C', + thermostatEditControlType: getParam('THERMOSTAT_CONTROL_TYPE') || 'hysteresis', + thermostatEditActiveSchedule: getParam('THERMOSTAT_ACTIVE_SCHEDULE') || '', + thermostatEditTemperatureFeature: getParam('THERMOSTAT_TEMPERATURE_FEATURE') || '', + thermostatEditHumidityFeature: getParam('THERMOSTAT_HUMIDITY_FEATURE') || '', + thermostatEditSwitchFeature: getParam('THERMOSTAT_SWITCH_FEATURE') || '', + thermostatEditType: getParam('THERMOSTAT_TYPE') || 'virtual', + thermostatEditTargetFeature: getParam('THERMOSTAT_TARGET_FEATURE') || '', + thermostatEditStateFeature: getParam('THERMOSTAT_STATE_FEATURE') || '', + thermostatEditModeFeature: getParam('THERMOSTAT_MODE_FEATURE') || '', + thermostatEditWindowFeature: getParam('THERMOSTAT_WINDOW_FEATURE') || '', + thermostatEditPresetFrost: getParam('THERMOSTAT_PRESET_FROST') || '7', + thermostatEditPresetAway: getParam('THERMOSTAT_PRESET_AWAY') || '16', + thermostatEditPresetEco: getParam('THERMOSTAT_PRESET_ECO') || '18', + thermostatEditPresetNight: getParam('THERMOSTAT_PRESET_NIGHT') || '17', + thermostatEditPresetComfort: getParam('THERMOSTAT_PRESET_COMFORT') || '21', + thermostatEditHysteresisStart: getParam('THERMOSTAT_HYSTERESIS_START') || '0.5', + thermostatEditHysteresisStop: getParam('THERMOSTAT_HYSTERESIS_STOP') || '0.5', + thermostatEditTpiCycleTime: getParam('THERMOSTAT_TPI_CYCLE_TIME') || '30', + thermostatEditTpiProportionalBand: getParam('THERMOSTAT_TPI_PROPORTIONAL_BAND') || '2', + thermostatEditRoomId: device.room_id || '', + thermostatEditManualDuration: getParam('THERMOSTAT_MANUAL_DURATION') || '30', + getThermostatDeviceStatus: RequestStatus.Success + }); + } catch (e) { + store.setState({ getThermostatDeviceStatus: RequestStatus.Error }); + } + }, + + updateThermostatField(state, field, value) { + store.setState({ [field]: value }); + }, + + updateThermostatUnit(state, newUnit) { + const oldUnit = state.thermostatEditTempUnit || 'C'; + if (oldUnit === newUnit) return; + const isSet = v => v !== '' && v !== null && v !== undefined; + const round = v => String(Math.round(v * 2) / 2); + // Absolute temperatures (setpoints, min/max) carry the 32° offset... + const toF = v => (isSet(v) ? round((parseFloat(v) * 9) / 5 + 32) : v); + const toC = v => (isSet(v) ? round(((parseFloat(v) - 32) * 5) / 9) : v); + // ...but hysteresis and the TPI band are temperature *differences*: adding + // the offset would turn a 0.5 °C hysteresis into 32.9 °F. + const deltaToF = v => (isSet(v) ? round((parseFloat(v) * 9) / 5) : v); + const deltaToC = v => (isSet(v) ? round((parseFloat(v) * 5) / 9) : v); + const conv = newUnit === 'F' ? toF : toC; + const convDelta = newUnit === 'F' ? deltaToF : deltaToC; + store.setState({ + thermostatEditTempUnit: newUnit, + thermostatEditMinTemp: conv(state.thermostatEditMinTemp), + thermostatEditMaxTemp: conv(state.thermostatEditMaxTemp), + thermostatEditPresetFrost: conv(state.thermostatEditPresetFrost), + thermostatEditPresetAway: conv(state.thermostatEditPresetAway), + thermostatEditPresetEco: conv(state.thermostatEditPresetEco), + thermostatEditPresetNight: conv(state.thermostatEditPresetNight), + thermostatEditPresetComfort: conv(state.thermostatEditPresetComfort), + thermostatEditHysteresisStart: convDelta(state.thermostatEditHysteresisStart), + thermostatEditHysteresisStop: convDelta(state.thermostatEditHysteresisStop), + thermostatEditTpiProportionalBand: convDelta(state.thermostatEditTpiProportionalBand) + }); + }, + + async saveThermostatDevice(state) { + store.setState({ thermostatCreateStatus: RequestStatus.Getting }); + try { + // `parseFloat(x) || d` turns a legitimate 0 into the default, so a 0 °C + // hysteresis band could never be saved. Fall back only when the input is + // not a finite number, like the server-side `toNumber` helper does. + const toNumber = (value, defaultValue) => { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : defaultValue; + }; + const toInt = (value, defaultValue) => { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : defaultValue; + }; + + const name = state.thermostatEditName || 'Thermostat'; + const mode = state.thermostatEditMode || 'heating'; + const minTemp = toNumber(state.thermostatEditMinTemp, 5); + const maxTemp = toNumber(state.thermostatEditMaxTemp, 35); + const tempUnit = state.thermostatEditTempUnit || 'C'; + const controlType = state.thermostatEditControlType || 'hysteresis'; + const temperatureFeature = state.thermostatEditTemperatureFeature || ''; + const humidityFeature = state.thermostatEditHumidityFeature || ''; + const switchFeature = state.thermostatEditSwitchFeature || ''; + const windowFeature = state.thermostatEditWindowFeature || ''; + // `|| default` would discard an explicitly entered 0, which the preset + // inputs accept: toNumber only falls back when the value is not a number. + const presetFrost = String(toNumber(state.thermostatEditPresetFrost, 7)); + const presetAway = String(toNumber(state.thermostatEditPresetAway, 16)); + const presetEco = String(toNumber(state.thermostatEditPresetEco, 18)); + const presetNight = String(toNumber(state.thermostatEditPresetNight, 17)); + const presetComfort = String(toNumber(state.thermostatEditPresetComfort, 21)); + const hysteresisStart = toNumber(state.thermostatEditHysteresisStart, 0.5); + const hysteresisStop = toNumber(state.thermostatEditHysteresisStop, 0.5); + const tpiCycleTime = toInt(state.thermostatEditTpiCycleTime, 30); + const tpiProportionalBand = toNumber(state.thermostatEditTpiProportionalBand, 2); + const manualDuration = toInt(state.thermostatEditManualDuration, 30); + + const isEdit = !!(state.thermostatEditDevice && state.thermostatEditDevice.selector); + const timestamp = Date.now(); + const slugName = name.toLowerCase().replace(/[^a-z0-9]/g, '-'); + const newExternalId = `thermostat:${slugName}-${timestamp}`; + + const thermostatType = state.thermostatEditType === 'external' ? 'external' : 'virtual'; + const isExternalThermostat = thermostatType === 'external'; + + const device = { + name, + external_id: isEdit ? state.thermostatEditDevice.external_id : newExternalId, + selector: isEdit ? state.thermostatEditDevice.selector : undefined, + should_poll: false, + // An external thermostat carries no setpoint feature of its own: the + // setpoint is the real device's, named by THERMOSTAT_TARGET_FEATURE. + // Creating a second one here would give the house two setpoints that + // drift apart. + features: isExternalThermostat + ? [] + : [ + { + // The thermostat/target-temperature category already means "setpoint" + // in every language; a hardcoded French suffix would leak into the + // device name shown in scenes, MQTT and every UI. + name, + external_id: isEdit + ? `${state.thermostatEditDevice.external_id}:target-temperature` + : `${newExternalId}:target-temperature`, + category: 'thermostat', + type: 'target-temperature', + read_only: false, + keep_history: true, + has_feedback: false, + min: minTemp, + max: maxTemp, + unit: tempUnit === 'F' ? 'fahrenheit' : 'celsius' + } + ], + room_id: state.thermostatEditRoomId || undefined, + params: [ + // The active schedule is device-owned: the dashboard widget only + // chooses which thermostat to display, it never drives regulation. + { name: 'THERMOSTAT_ACTIVE_SCHEDULE', value: state.thermostatEditActiveSchedule || '' }, + { name: 'THERMOSTAT_MODE', value: mode }, + { name: 'THERMOSTAT_MIN_TEMP', value: String(minTemp) }, + { name: 'THERMOSTAT_MAX_TEMP', value: String(maxTemp) }, + { name: 'THERMOSTAT_TEMP_UNIT', value: tempUnit }, + { name: 'THERMOSTAT_CONTROL_TYPE', value: controlType }, + { name: 'THERMOSTAT_TEMPERATURE_FEATURE', value: temperatureFeature }, + { name: 'THERMOSTAT_HUMIDITY_FEATURE', value: humidityFeature }, + { name: 'THERMOSTAT_SWITCH_FEATURE', value: isExternalThermostat ? '' : switchFeature }, + { name: 'THERMOSTAT_TYPE', value: thermostatType }, + // Only meaningful on an external thermostat, and cleared otherwise so + // switching a device back to virtual cannot leave it driving a real + // one through a stale param. + { + name: 'THERMOSTAT_TARGET_FEATURE', + value: isExternalThermostat ? state.thermostatEditTargetFeature || '' : '' + }, + { + name: 'THERMOSTAT_STATE_FEATURE', + value: isExternalThermostat ? state.thermostatEditStateFeature || '' : '' + }, + { + name: 'THERMOSTAT_MODE_FEATURE', + value: isExternalThermostat ? state.thermostatEditModeFeature || '' : '' + }, + { name: 'THERMOSTAT_WINDOW_FEATURE', value: windowFeature }, + { name: 'THERMOSTAT_PRESET_FROST', value: presetFrost }, + { name: 'THERMOSTAT_PRESET_AWAY', value: presetAway }, + { name: 'THERMOSTAT_PRESET_ECO', value: presetEco }, + { name: 'THERMOSTAT_PRESET_NIGHT', value: presetNight }, + { name: 'THERMOSTAT_PRESET_COMFORT', value: presetComfort }, + { name: 'THERMOSTAT_HYSTERESIS_START', value: String(hysteresisStart) }, + { name: 'THERMOSTAT_HYSTERESIS_STOP', value: String(hysteresisStop) }, + { name: 'THERMOSTAT_TPI_CYCLE_TIME', value: String(tpiCycleTime) }, + { name: 'THERMOSTAT_TPI_PROPORTIONAL_BAND', value: String(tpiProportionalBand) }, + { name: 'THERMOSTAT_MANUAL_DURATION', value: String(manualDuration) } + ] + }; + + // The device is the single store for the configuration: every field above + // is a device param. Writing a THERMOSTAT_CONFIG_* variable as well would + // reintroduce two sources of truth for the same settings, and a failure + // between the two writes would leave them disagreeing. + await state.httpClient.post('/api/v1/service/thermostat/device', device); + + store.setState({ + thermostatCreateStatus: RequestStatus.Success, + thermostatEditDevice: null, + thermostatEditName: '', + thermostatEditMode: 'heating', + thermostatEditMinTemp: '5', + thermostatEditMaxTemp: '35', + thermostatEditTempUnit: 'C', + thermostatEditControlType: 'hysteresis', + thermostatEditTemperatureFeature: '', + thermostatEditHumidityFeature: '', + thermostatEditSwitchFeature: '', + thermostatEditType: 'virtual', + thermostatEditTargetFeature: '', + thermostatEditStateFeature: '', + thermostatEditModeFeature: '', + thermostatEditWindowFeature: '', + thermostatEditActiveSchedule: '', + thermostatEditPresetFrost: '7', + thermostatEditPresetAway: '16', + thermostatEditPresetEco: '18', + thermostatEditPresetNight: '17', + thermostatEditPresetComfort: '21', + thermostatEditHysteresisStart: '0.5', + thermostatEditHysteresisStop: '0.5', + thermostatEditTpiCycleTime: '30', + thermostatEditTpiProportionalBand: '2', + thermostatEditRoomId: '', + thermostatEditManualDuration: '30' + }); + route('/dashboard/integration/device/thermostat'); + } catch (e) { + store.setState({ thermostatCreateStatus: RequestStatus.Error }); + } + } + }; + + return Object.assign({}, houseActions, actions); +} + +export default createActions; diff --git a/front/src/routes/integration/all/thermostat/edit-page/index.js b/front/src/routes/integration/all/thermostat/edit-page/index.js new file mode 100644 index 0000000000..13ec271ed9 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/index.js @@ -0,0 +1,71 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import actions from './actions'; +import ThermostatPage from '../ThermostatPage'; +import EditForm from './EditForm'; + +class ThermostatEditPage extends Component { + // Extracted so a selector change on a reused route reloads the form. Doing this + // only in componentWillMount left the previous device's values on screen when + // navigating from one thermostat's edit page to another's. + loadForSelector(deviceSelector) { + if (deviceSelector) { + this.props.getThermostatDevice(deviceSelector); + return; + } + this.props.updateThermostatField('thermostatEditDevice', null); + this.props.updateThermostatField('thermostatEditName', ''); + this.props.updateThermostatField('thermostatEditMode', 'heating'); + this.props.updateThermostatField('thermostatEditMinTemp', '5'); + this.props.updateThermostatField('thermostatEditMaxTemp', '35'); + this.props.updateThermostatField('thermostatEditTempUnit', 'C'); + this.props.updateThermostatField('thermostatEditControlType', 'hysteresis'); + this.props.updateThermostatField('thermostatEditActiveSchedule', ''); + this.props.updateThermostatField('thermostatEditTemperatureFeature', ''); + this.props.updateThermostatField('thermostatEditHumidityFeature', ''); + this.props.updateThermostatField('thermostatEditSwitchFeature', ''); + this.props.updateThermostatField('thermostatEditType', 'virtual'); + this.props.updateThermostatField('thermostatEditTargetFeature', ''); + this.props.updateThermostatField('thermostatEditStateFeature', ''); + this.props.updateThermostatField('thermostatEditModeFeature', ''); + this.props.updateThermostatField('thermostatEditWindowFeature', ''); + this.props.updateThermostatField('thermostatEditPresetFrost', '7'); + this.props.updateThermostatField('thermostatEditPresetAway', '16'); + this.props.updateThermostatField('thermostatEditPresetEco', '18'); + this.props.updateThermostatField('thermostatEditPresetNight', '17'); + this.props.updateThermostatField('thermostatEditPresetComfort', '21'); + this.props.updateThermostatField('thermostatEditHysteresisStart', '0.5'); + this.props.updateThermostatField('thermostatEditHysteresisStop', '0.5'); + this.props.updateThermostatField('thermostatEditTpiCycleTime', '30'); + this.props.updateThermostatField('thermostatEditTpiProportionalBand', '2'); + this.props.updateThermostatField('thermostatEditRoomId', ''); + this.props.updateThermostatField('thermostatEditManualDuration', '30'); + this.props.updateThermostatField('thermostatCreateStatus', null); + } + + componentWillMount() { + this.props.getDevicesForThermostatEdit(); + this.props.getHouses(); + this.props.getSchedules(); + this.loadForSelector(this.props.deviceSelector); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.deviceSelector !== this.props.deviceSelector) { + this.loadForSelector(nextProps.deviceSelector); + } + } + + render(props) { + return ( + + + + ); + } +} + +export default connect( + 'user,houses,thermostatEditDevice,thermostatEditName,thermostatEditMode,thermostatEditMinTemp,thermostatEditMaxTemp,thermostatEditTempUnit,thermostatEditControlType,thermostatEditTemperatureFeature,thermostatEditHumidityFeature,thermostatEditSwitchFeature,thermostatEditType,thermostatEditTargetFeature,thermostatEditStateFeature,thermostatEditModeFeature,thermostatEditWindowFeature,thermostatEditPresetFrost,thermostatEditPresetAway,thermostatEditPresetEco,thermostatEditPresetNight,thermostatEditPresetComfort,thermostatEditHysteresisStart,thermostatEditHysteresisStop,thermostatEditTpiCycleTime,thermostatEditTpiProportionalBand,thermostatEditRoomId,thermostatEditManualDuration,thermostatEditActiveSchedule,thermostatSchedules,thermostatCreateStatus,temperatureFeatures,humidityFeatures,switchFeatures,openingFeatures,targetFeatures,stateFeatures,modeFeatures', + actions +)(ThermostatEditPage); diff --git a/front/src/routes/integration/all/thermostat/edit-page/style.css b/front/src/routes/integration/all/thermostat/edit-page/style.css new file mode 100644 index 0000000000..7498e41b41 --- /dev/null +++ b/front/src/routes/integration/all/thermostat/edit-page/style.css @@ -0,0 +1,24 @@ +.presetColName { + width: 30%; +} + +.presetTempInput { + max-width: 90px; +} + +/* Preset colour swatch: only the colour itself is dynamic, passed as a CSS + variable. The dark theme inverts the whole page, so re-invert the dot to + keep its real colour — same handling as the schedule editor dot. */ +.presetColorDot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 6px; + flex-shrink: 0; + background: var(--dot-color, #adb5bd); +} + +:global(.dark-mode) .presetColorDot { + filter: invert(100%) hue-rotate(180deg); +} diff --git a/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx new file mode 100644 index 0000000000..45ebbe495d --- /dev/null +++ b/front/src/routes/integration/all/thermostat/schedule-page/ScheduleEditor.jsx @@ -0,0 +1,722 @@ +import { Component } from 'preact'; +import { Text } from 'preact-i18n'; +import cx from 'classnames'; +import style from './style.css'; +import PRESET_COLORS from '../../../../../utils/thermostatPresetColors'; +// The slot algebra is shared with the server rather than reimplemented here: +// the editor and the regulation loop must agree on what a slot list means. +import { + applySlotToDay, + mergeIntoSlots, + copyDayOntoDays, + readDayAsEntered, + timeToMinutes, + minutesToTime, + DAY_MINUTES +} from '../../../../../../../server/utils/thermostatSchedule'; + +const DAYS = [0, 1, 2, 3, 4, 5, 6]; +const PRESETS = ['off', 'frost', 'away', 'eco', 'night', 'comfort']; +const FIXED_MARKERS = [6 * 60, 12 * 60, 18 * 60]; + +function formatLabel(minutes) { + const h = Math.floor(minutes / 60) % 24; + const m = minutes % 60; + return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`; +} + +function ensureKeys(slots) { + return slots.map((s, i) => (s.key ? s : { ...s, key: Date.now() + i + Math.random() })); +} + +class ScheduleEditor extends Component { + constructor(props) { + super(props); + this.state = { + name: props.schedule ? props.schedule.name : '', + slots: ensureKeys(props.schedule ? props.schedule.slots : []), + saving: false, + error: null, + selectedDay: null, + lastScheduleSelector: props.schedule ? props.schedule.selector : null, + copySourceDay: null, + copyTargetDays: [], + newSlotForms: {}, // { [day]: { start_time, end_time, preset } } + editForms: {} // { [key]: { start_time, end_time, preset, day_of_week } } + }; + } + + static getDerivedStateFromProps(props, state) { + const incomingSelector = props.schedule ? props.schedule.selector : null; + if (incomingSelector !== state.lastScheduleSelector) { + return { + name: props.schedule ? props.schedule.name : '', + slots: ensureKeys(props.schedule ? props.schedule.slots : []), + error: null, + selectedDay: null, + lastScheduleSelector: incomingSelector, + newSlotForms: {}, + editForms: {} + }; + } + return null; + } + + updateName = e => this.setState({ name: e.target.value }); + + selectDay = day => { + this.setState(prev => ({ selectedDay: prev.selectedDay === day ? null : day })); + }; + + // ── New slot ────────────────────────────────────────────────────────────── + + openNewSlotForm = dayOfWeek => { + const daySlots = this.state.slots + .filter(s => s.day_of_week === dayOfWeek) + .sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)); + + // Default: fill the first uncovered gap, or full day if no slots + let startMins = 0; + let endMins = 0; // 00:00 = full day (midnight) + if (daySlots.length > 0) { + startMins = timeToMinutes(daySlots[daySlots.length - 1].end_time) || DAY_MINUTES; + startMins = Math.min(startMins, DAY_MINUTES - 60); + endMins = Math.min(startMins + 120, DAY_MINUTES) % DAY_MINUTES; + } + + this.setState(prev => ({ + newSlotForms: { + ...prev.newSlotForms, + [dayOfWeek]: { + start_time: minutesToTime(startMins), + end_time: minutesToTime(endMins), + preset: 'comfort' + } + } + })); + }; + + closeNewSlotForm = dayOfWeek => { + this.setState(prev => { + const forms = { ...prev.newSlotForms }; + delete forms[dayOfWeek]; + return { newSlotForms: forms }; + }); + }; + + updateNewSlotForm = (dayOfWeek, field, value) => { + this.setState(prev => ({ + newSlotForms: { + ...prev.newSlotForms, + [dayOfWeek]: { ...prev.newSlotForms[dayOfWeek], [field]: value } + } + })); + }; + + confirmNewSlot = dayOfWeek => { + const form = this.state.newSlotForms[dayOfWeek]; + if (!form) return; + + const newStart = timeToMinutes(form.start_time); + let newEnd = timeToMinutes(form.end_time); + // If end <= start, the user wants overflow past midnight (e.g. 18h→06h) + if (newEnd <= newStart) newEnd = newEnd + DAY_MINUTES; + + const newKey = Date.now() + Math.random(); + const existingDaySlots = this.state.slots.filter(s => s.day_of_week === dayOfWeek); + + const { fixedSlots, overflowSlot } = applySlotToDay( + existingDaySlots, + dayOfWeek, + newStart, + newEnd, + form.preset, + newKey, + null + ); + const taggedFixed = fixedSlots.map(s => ({ ...s, day_of_week: dayOfWeek })); + const finalSlots = mergeIntoSlots(this.state.slots, dayOfWeek, taggedFixed, overflowSlot); + + this.setState(prev => { + const forms = { ...prev.newSlotForms }; + delete forms[dayOfWeek]; + return { slots: finalSlots, newSlotForms: forms }; + }); + }; + + // ── Edit existing slot ──────────────────────────────────────────────────── + + openEditForm = slot => { + this.setState(prev => ({ + editForms: { + ...prev.editForms, + [slot.key]: { + start_time: slot.start_time, + end_time: slot.end_time, + preset: slot.preset, + day_of_week: slot.day_of_week + } + } + })); + }; + + closeEditForm = slotKey => { + this.setState(prev => { + const forms = { ...prev.editForms }; + delete forms[slotKey]; + return { editForms: forms }; + }); + }; + + updateEditForm = (slotKey, field, value) => { + this.setState(prev => ({ + editForms: { + ...prev.editForms, + [slotKey]: { ...prev.editForms[slotKey], [field]: value } + } + })); + }; + + confirmEdit = slotKey => { + const form = this.state.editForms[slotKey]; + if (!form) return; + + const { day_of_week: dayOfWeek } = form; + const newStart = timeToMinutes(form.start_time); + let newEnd = timeToMinutes(form.end_time); + // If end <= start, the user wants overflow past midnight (e.g. 18h→06h) + if (newEnd <= newStart) newEnd = newEnd + DAY_MINUTES; + + // Drop the morning half of the night being edited first: mergeIntoSlots + // only trims what the new overflow overlaps, so shortening 22:30->06:30 + // to 05:00 would leave a stray 05:00->06:30 behind. + const edited = this.state.slots.find(s => s.key === slotKey); + const piece = this.findOvernightPiece(this.state.slots, edited); + const baseSlots = piece ? this.state.slots.filter(s => s.key !== piece.key) : this.state.slots; + const existingDaySlots = baseSlots.filter(s => s.day_of_week === dayOfWeek); + + const { fixedSlots, overflowSlot } = applySlotToDay( + existingDaySlots, + dayOfWeek, + newStart, + newEnd, + form.preset, + slotKey, + slotKey + ); + const taggedFixed = fixedSlots.map(s => ({ ...s, day_of_week: dayOfWeek })); + const finalSlots = mergeIntoSlots(baseSlots, dayOfWeek, taggedFixed, overflowSlot); + + this.setState(prev => { + const forms = { ...prev.editForms }; + delete forms[slotKey]; + return { slots: finalSlots, editForms: forms }; + }); + }; + + // ── Remove ──────────────────────────────────────────────────────────────── + + // The morning half a night left on the next day, matched on geometry the way + // readDayAsEntered does. The list shows the pair as one slot, so editing or + // removing that slot has to reach this row too — otherwise it survives as an + // orphan the user has no way to see, let alone delete. + findOvernightPiece = (slots, slot) => { + if (!slot || timeToMinutes(slot.end_time) !== 0) { + return null; + } + return ( + slots.find( + s => + s.day_of_week === (slot.day_of_week + 1) % 7 && + timeToMinutes(s.start_time) === 0 && + timeToMinutes(s.end_time) !== 0 && + s.preset === slot.preset + ) || null + ); + }; + + removeSlot = slotKey => { + this.setState(prev => { + const removed = prev.slots.find(s => s.key === slotKey); + const piece = this.findOvernightPiece(prev.slots, removed); + const dropped = new Set([slotKey, ...(piece ? [piece.key] : [])]); + const forms = { ...prev.editForms }; + delete forms[slotKey]; + return { slots: prev.slots.filter(s => !dropped.has(s.key)), editForms: forms }; + }); + }; + + // ── Copy ────────────────────────────────────────────────────────────────── + + openCopyPicker = dayOfWeek => this.setState({ copySourceDay: dayOfWeek, copyTargetDays: [] }); + closeCopyPicker = () => this.setState({ copySourceDay: null, copyTargetDays: [] }); + + toggleCopyTarget = day => { + this.setState(prev => { + const set = new Set(prev.copyTargetDays || []); + if (set.has(day)) { + set.delete(day); + } else { + set.add(day); + } + return { copyTargetDays: Array.from(set) }; + }); + }; + + applyCopy = () => { + const { copySourceDay, copyTargetDays, slots } = this.state; + if (!copyTargetDays || copyTargetDays.length === 0) { + this.closeCopyPicker(); + return; + } + // A night crossing midnight lives as two rows, the second one on the next + // day: copying the source day's rows alone would drop its morning half and + // overwrite that same half on a target. copyDayOntoDays re-joins the pair + // and lays it back down on every target. + const nextSlots = copyDayOntoDays(slots, copySourceDay, copyTargetDays, () => Date.now() + Math.random()); + this.setState({ slots: nextSlots, copySourceDay: null, copyTargetDays: [] }); + }; + + // ── Validation ──────────────────────────────────────────────────────────── + + // Uncovered ranges, per day. A gap is not an error: the regulation loop falls + // back on the current preset when no slot matches, which is what a + // daytime-only schedule (offices, 08:00 → 18:00) relies on. It is reported as + // a warning so an unintended hole is still visible before saving. + validateSchedule = () => { + const { slots } = this.state; + const gaps = []; + DAYS.forEach(day => { + const daySlots = slots + .filter(s => s.day_of_week === day) + .map(s => ({ + start: timeToMinutes(s.start_time), + end: timeToMinutes(s.end_time) || DAY_MINUTES + })) + .sort((a, b) => a.start - b.start); + + const ranges = []; + let covered = 0; + daySlots.forEach(s => { + if (s.start > covered) { + ranges.push({ from: covered, to: s.start }); + } + covered = Math.max(covered, s.end); + }); + if (covered < DAY_MINUTES) { + ranges.push({ from: covered, to: DAY_MINUTES }); + } + if (ranges.length > 0) { + gaps.push({ day, ranges }); + } + }); + return gaps; + }; + + // ── Save ────────────────────────────────────────────────────────────────── + + save = async () => { + const { name, slots } = this.state; + if (!name.trim()) return; + + // Gaps no longer block: they are surfaced as a warning above the form. + this.setState({ saving: true, error: null }); + const scheduleData = { + name: name.trim(), + // key is a render-only handle, and id/schedule_id belong to the row being + // replaced: neither is part of what a slot means. + slots: slots.map(({ key, id, schedule_id, ...rest }) => rest) + }; + try { + const { schedule, httpClient, onSaved } = this.props; + // A duplicate arrives as a schedule object with no selector: it is a + // creation, so gating on the object alone would PATCH /schedule/null. + if (schedule && schedule.selector) { + await httpClient.patch(`/api/v1/service/thermostat/schedule/${schedule.selector}`, scheduleData); + } else { + await httpClient.post('/api/v1/service/thermostat/schedule', scheduleData); + } + if (onSaved) onSaved(); + } catch (e) { + const msg = (e && e.response && e.response.data && e.response.data.message) || true; + this.setState({ saving: false, error: msg }); + } + }; + + // ── Render helpers ──────────────────────────────────────────────────────── + + // Text equivalent of the coloured bar, for a collapsed day. + describeDay(daySlots, dictionary) { + if (!daySlots || daySlots.length === 0) { + return dictionary.noSlots || ''; + } + return daySlots + .slice() + .sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)) + .map(slot => { + const preset = (dictionary.presets && dictionary.presets[slot.preset]) || slot.preset; + return `${preset} ${slot.start_time} – ${slot.end_time}`; + }) + .join(', '); + } + + renderTimeBar(daySlots) { + const sorted = daySlots.slice().sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time)); + const segments = []; + sorted.forEach(slot => { + const start = timeToMinutes(slot.start_time); + const end = Math.min(timeToMinutes(slot.end_time) || DAY_MINUTES, DAY_MINUTES); + if (end <= start) return; + segments.push({ start, end, preset: slot.preset }); + }); + + const allPoints = Array.from(new Set([0, ...segments.flatMap(s => [s.start, s.end]), DAY_MINUTES])).sort( + (a, b) => a - b + ); + + const barParts = []; + for (let i = 0; i < allPoints.length - 1; i++) { + const from = allPoints[i]; + const to = allPoints[i + 1]; + const widthPct = ((to - from) / DAY_MINUTES) * 100; + const seg = segments.find(s => s.start <= from && s.end >= to); + const color = seg ? PRESET_COLORS[seg.preset] || '#ddd' : '#e9ecef'; + barParts.push({ from, to, widthPct, color }); + } + + return ( + // Purely visual: the colours carry no text and the hour markers would be + // read as loose numbers. describeDay states the same thing in words. +