From eb325cc0c8fecc315ab9323c5cd5bf4ff3b5089d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 28 Aug 2026 10:04:35 +0200 Subject: [PATCH] v0.8.4 --- BUILD_REPORT.md | 620 +----------------- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 28 +- README.md | 13 +- docs/API.md | 14 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 12 + lang/pl.json | 12 + src/api.rs | 91 ++- src/engine.rs | 151 ++++- src/models.rs | 16 + web/app.js | 63 +- web/index.html | 17 +- web/sw.js | 2 +- 15 files changed, 392 insertions(+), 653 deletions(-) diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 4b7c89a..487c283 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,26 +1,45 @@ -# GREE Controller v0.8.3 - build and validation report +# GREE Controller v0.8.4 - build and validation report -## 0.8.3 Temporary Quick Thermostat sessions +## 0.8.4 Scheduled Temporary Quick Thermostat start and ownership hardening -Source baseline: v0.8.2. +Source baseline: v0.8.3. ### Changes -- Added a separate persisted Temporary Quick Thermostat session, independent of the existing `local_thermostat_resume_at` 15-minute local-OFF hand-back mechanism. -- Added finish rules for duration, absolute date/time, temperature reached, continuously held temperature and next schedule boundary. -- Temperature rules support `within`, `at_or_below` and `at_or_above`, configurable tolerance, continuous hold time and an optional maximum safety duration. -- Added active-session countdown/status in the dashboard Quick Thermostat card and a configuration dialog for starting or updating a session. -- Moved the zone automation enabled switch outside the Quick Thermostat controls to separate automation availability from local thermostat operation. -- A temporary session can temporarily enable a disabled zone and restores the pre-session enabled state when it ends. -- Manual Quick Thermostat power changes cancel the temporary session, while target changes update temperature-based session targets safely. -- Bumped controller and Home Assistant integration version to 0.8.3 and rotated the PWA service-worker cache key. +- Added independent Temporary Quick Thermostat start rules: immediately, after a delay, or at an absolute date/time. +- Delayed sessions are persisted without taking local thermostat ownership before `started_at`; the controller wakes at the scheduled start and activates the saved target then. +- Duration, absolute finish validation, safety timeout and next-schedule-boundary calculations are anchored to the effective start time. +- Added pending-session countdown/status to the Quick Thermostat button and modal; after activation the same control switches to the existing finish countdown/status. +- Active Temporary Quick Thermostat targets keep priority over schedule transitions and schedule edits. +- Direct device automations remain blocked by local thermostat ownership while the temporary session is active; group climate mode/preset actions are deferred for an actively owned zone, while group power state may change underneath and becomes effective after hand-back. +- Before a delayed start, normal schedule/automation ownership is unchanged unless another pre-existing local/manual override already owns the zone. +- Local Quick Thermostat ON can use the zone's last Heat/Cool mode when inherited house climate mode is `off`; whole-house master power remains authoritative. +- Cancelling a pending session does not clear unrelated manual quick-control state. +- Added Rust regression tests for delayed-session ownership, pre-start temperature conditions, schedule target priority and local Quick Thermostat operation with inherited house mode `off`. +- Bumped controller and Home Assistant integration version to 0.8.4 and rotated the PWA service-worker cache key. + +### Ownership/priority scenarios reviewed + +| Scenario | Expected result | +| --- | --- | +| Delayed session before start | Normal schedule and automations continue; pending session owns nothing yet. | +| Start time reached | Session acquires local thermostat ownership, enables the zone if needed and applies its saved target. | +| Schedule transitions while active | Schedule may advance internally, but `manual_setpoint` from Temporary Quick Thermostat remains effective. | +| Schedule edited while active | Generic schedule-boundary override timer is not re-armed, so the temporary target is not cleared. | +| Direct-device automation while active | Suppressed by local thermostat ownership. | +| Group mode/preset automation while active | Climate change is deferred for the owned zone; it cannot clear/change the temporary target. | +| Group power automation while active | Group gate can change underneath, but local temporary ownership keeps the zone running; the gate applies after hand-back. | +| Temporary finish | Local ownership is cleared and the currently applicable schedule/group/house state is recalculated. | +| Session started from disabled zone | Zone is temporarily enabled, then restored to disabled and its unit is stopped after hand-back. | ### Validation executed in this environment - `node --check web/app.js` - `node --check web/theme-init.js` - `node --check web/sw.js` -- parsed all JSON files +- parsed all JSON files, including PL/EN language packs and Home Assistant manifest +- checked duplicate HTML IDs +- checked that newly referenced PL/EN translation keys exist - `python3 -m compileall -q home-assistant scripts make_zip.py` - `bash -n scripts/*.sh` - checked version consistency across Cargo.toml, Cargo.lock, README and Home Assistant manifest @@ -29,581 +48,6 @@ Source baseline: v0.8.2. ### Rust toolchain note -This execution environment does not contain `cargo`, `rustc` or `rustfmt`, so Rust tests and full compilation cannot be executed here. The installer/updater still runs `cargo test --all-targets` before replacing the running service. +This execution environment does not contain `cargo`, `rustc` or `rustfmt`, so the Rust regression tests and full compilation cannot be executed here. The tests are included in `src/engine.rs`, and the installer/updater still runs `cargo test --all-targets` before replacing the running service. --- - -# GREE Controller v0.8.2 - build and validation report - -## 0.8.2 UI/i18n + Home Assistant sensor freshness - -Source baseline: v0.8.1. - -### Changes - -- Fixed the 0.8.x ownership translations: `zones.controlOwner`, owner labels and owner timing labels are now inside each language pack's `translations` object, so the Web UI receives translated values instead of raw translation keys. -- Simplified the Polish owner caption from `Właściciel sterowania` to `Sterowanie` and allowed the owner/timing line to wrap instead of truncating. -- Added `home_assistant.sensor_stale_after_seconds` with a serde-compatible 300 s default. The Home Assistant / Sensors page exposes it as minutes (1-1440 min); the backend clamps API/env values to 30-86400 s. -- The configured freshness limit is used by room HA sensors, the outdoor HA sensor and the HA connection test. Existing non-default `zone.sensor_stale_after_seconds` values remain per-zone API overrides; the historical hidden/default 300 s value inherits the global HA setting. -- Added `HA_SENSOR_STALE_AFTER_SECONDS` as an optional environment override. -- Quick Thermostat and Direct Device dashboard grids now use `align-items:start`, so a taller manual/ownership panel no longer visually stretches neighbouring cards in the same row. -- `build.rs` now rejects unexpected top-level language-pack keys, preventing translation keys from being accidentally stored outside `translations` again. -- Added a Rust regression test for global/per-zone stale-timeout resolution. -- Bumped controller and Home Assistant integration version to 0.8.2 and changed the service-worker cache key. - -### Validation executed in this environment - -- `node --check web/app.js` -- `node --check web/theme-init.js` -- `node --check web/sw.js` -- parsed all JSON files -- verified both language packs contain only `meta` + `translations` -- verified all 564 statically referenced UI translation keys exist in both English and Polish packs -- parsed `web/index.html` -- `python3 -m compileall -q home-assistant scripts make_zip.py` -- `bash -n scripts/*.sh` -- checked version consistency across Cargo.toml, Cargo.lock, README and Home Assistant manifest -- checked that no HA temperature read still uses a hardcoded `Some(300)` freshness limit -- reviewed the complete 0.8.1 -> 0.8.2 source diff for unintended file changes - -### Rust toolchain note - -This execution environment does not contain `cargo`, `rustc` or `rustfmt`, so the Rust regression test and full compilation cannot be executed here. The existing installer/updater still runs `cargo test --all-targets` before touching the running service and then performs the release build. If Rust compilation or tests fail on the target host, the running service is not replaced. - ---- - -# GREE Controller v0.8.1 - build and validation report - -## 0.8.1 Rust borrow-checker hotfix - -Version 0.8.1 is a compile-only hotfix for the 0.8.0 ownership release. It does not change controller behavior. Two mode-selection values in `src/engine.rs` borrowed `zone.mode` as `&str` across `refresh_control_ownership(&mut zone, ...)`, which Rust correctly rejected with E0502. Both paths now clone the selected mode into an owned `String` before the mutable ownership refresh and create the `&str` view only afterwards. - -The affected paths are the live thermostat engine loop and `/api/control-plan` construction. No persisted schema, API field, ownership rule, thermostat decision, lockout rule or Home Assistant entity semantics changed. - -### Validation available in this environment - -- Confirmed both E0502 source patterns are removed and no `zone.mode.as_str()` borrow is held across `refresh_control_ownership(&mut zone, ...)`. -- `node --check` passed for Web JavaScript. -- All JSON files parse successfully. -- Python Home Assistant integration and helper scripts pass `py_compile`. -- Shell scripts pass `bash -n`. -- Version consistency checked for Cargo package, Cargo lock root package, README and Home Assistant manifest. -- This packaging container still has no `cargo`, `rustc` or `rustfmt`; therefore the target-host update script remains the authoritative Rust compile/test gate and must run `cargo test --all-targets` and `cargo build --release` before replacing the service. - ---- - -# GREE Controller v0.8.0 - build and validation report - -## 0.8.0 ownership/concurrency/safety release - -Version 0.8.0 keeps the 0.7.13 public behavior while adding explicit control ownership, per-zone serialization and optimistic revisions, stale Home Assistant sensor protection, schedule-boundary recalculation for physical manual takeover, automatic Heat/Cool compressor lockout, desired-vs-actual diagnostics, and owner/source status in Web/Home Assistant. - -### Compatibility - -- Existing zone JSON remains loadable because every new persisted field uses serde defaults. -- The `revision` request field is optional, so older Web/API clients continue to work; clients that send it receive HTTP 409 on stale writes. -- Existing `control-plan.control_source` keeps its historical meaning (temperature source). The new command provenance is exported as `control_command_source` to avoid breaking Home Assistant or third-party consumers. -- Existing physical and zone climate entity unique IDs remain unchanged. - -### Validation available in this environment - -- `node --check` passed for Web JavaScript. -- All JSON files parse successfully. -- Python Home Assistant integration and helper scripts pass `py_compile`. -- Shell scripts pass `bash -n`. -- Structural checks confirm all `Zone` and `ZoneControlPlan` literals contain the newly required fields. -- A Rust compiler/toolchain is not installed in this container and external network access from the container is disabled, so `cargo fmt/check/test` cannot be executed here. The release/update scripts must still run the normal Cargo build/test gate on the target host before replacing the service binary. - ---- - -# GREE Controller v0.7.13 - build and validation report - -## Scope - -Version 0.7.13 hardens Quick Thermostat ownership against polling/firmware-settling races, disables Global when no global Heating/Cooling mode is selected, and adds live counters for UDP frames received from configured GREE units. The 0.7.12 timed hand-back behavior remains unchanged. - -### 0.7.13 race protection and diagnostics - -- Per-zone Quick Thermostat changes are serialized with polling using the same per-device operation lock, preventing a stale poll from restoring a false physical/pilot takeover after the user explicitly selects local thermostat control. -- Recent controller-requested states and their pre-command baselines are kept for a bounded settling window, so delayed/out-of-order GREE status frames are not mistaken for remote-control changes. -- The Quick Thermostats **Global** button is disabled while the global house mode is neither Heating nor Cooling. -- Settings shows live total and per-device UDP receive counters, updated over the existing WebSocket event stream. - -### Existing 0.7.12 hand-back correction - -- Local thermostat ON/OFF now goes through one backend helper, so every new OFF action creates a fresh authoritative 15-minute deadline and ON always cancels it. -- A physical/direct-device takeover temporarily suspends expiry of an existing local-OFF hand-back. Returning the unit to the previous OFF state re-arms the countdown from that return moment instead of continuing an old timer. -- The Home Assistant coordinator keeps the exact `local_thermostat_resume_at` returned by the zone-control API in its short optimistic overlay, preventing a stale in-flight plan refresh from briefly restoring an older countdown. - -## Implemented changes - -- Night-mode settings moved out of application Settings into a dedicated **Night mode** page. -- Home Assistant connection, sensor aliases and integration access tokens moved to a dedicated **Home Assistant / Sensors** page. -- Settings now contains application/technical settings only. -- Unit-feature controls on device cards are smaller, centered and visually aligned with the other compact device controls. -- Disabled thermostat zones continue refreshing and showing room temperature while control commands remain disabled. -- Home Assistant sensor aliases are now presentation-only end-to-end: backend reads resolve aliases back to the original `entity_id`, settings are canonicalized, and zone/outdoor polling records the real entity ID. -- Outdoor temperature now falls back to the median outdoor reading from online GREE units when the configured Home Assistant outdoor sensor is unavailable. -- GREE optional capabilities are discovered from status properties (`Lig`, `Quiet`, `Tur`, `Air`, `Blo`, `Health`, `SwhSlp`) and persisted with each device. -- Supported unit features are shown in the Web UI and exposed as Home Assistant switches for panel light, Quiet, Turbo, X-FAN, Air, Health and Sleep. -- Night mode can use native GREE Sleep in the same command as the standby setpoint, fan limit and Quiet when the unit supports it. -- Home Assistant now exposes every controller zone as a full `climate` entity with Off / Auto (follow house mode) / Cool / Heat plus current and target temperature. Existing zone number/switch entities are retained. -- Decorative icons were removed from Settings block headers. -- Replaced the textual Connected/Disconnected label with a green/amber/red status dot in the top-left brand header. -- Fixed History filter layout so Zone/Device/Sensor and time-range controls keep equal heights and do not stretch after changing tabs. -- Reworked the Simulator into a Node-RED-style dotted flow board with connected live nodes for sensor input, thermostat target, decision, unit command and next event. -- Added configurable night mode (default 22:00-06:00): thermostat fan speed is capped during quiet hours and compatible units can be asked to keep Quiet enabled. -- Device cards now use **Name / protocol** instead of Rename and no longer show the fixed UDP port next to the IP address. -- Settings are split into visual blocks; the self-signed HA example uses a generic private address instead of an installation-specific address. -- Event/debug log retention is configurable and old rows are pruned automatically; the Events page can change retention and prune immediately. -- Home Assistant temperature sensors can have UI aliases; aliases replace raw entity IDs in history selectors, legends and custom-chart labels without changing stored entity IDs. -- Added a dedicated Web UI simulator page that visualizes thermostat/automation decisions as graphical blocks with hours, targets, demand, standby behavior, rule actions and upcoming events. -- Quick Thermostat cards on the dashboard can enable/disable a zone directly; the edit dialog is no longer required for this action. -- SQLite history housekeeping is tiered to chart resolution: full recent data, 10-minute buckets after 24 hours and 30-minute buckets after 7 days. -- Optional long-term InfluxDB archive supports both InfluxDB 1.x (database/basic auth) and 2.x (org/bucket/token), from persisted settings or environment variables. -- New samples are written to SQLite and InfluxDB in parallel when the archive is enabled. -- Maintenance backfills compacted legacy SQLite samples older than the configured Influx handoff age. A local row is deleted only after the Influx batch succeeds; archive failures keep the SQLite copy. -- History requests crossing the handoff age merge old InfluxDB data with recent SQLite data and fall back to SQLite when the archive query fails. -- Device commands are reduced to fields that actually changed. Optional GREE buzzer suppression uses `Buzzer_ON_OFF`/`BuzzerCtrl`; incompatible devices automatically fall back to normal frames and are remembered for the running process. -- Smart Fan now uses Low speed (`WdSpd=1`) when a zone is satisfied. The thermostat sends standby setpoint + Low fan + optional `Quiet=1` in one GREE frame. If a unit rejects `Quiet`, the controller retries without it and remembers that capability fallback until restart; when demand resumes, controller-owned Quiet is released on the transition. -- Toast progress indicators have horizontal inset so they stay inside rounded toast corners. -- Settings can be exported/imported as JSON. Metrics, event logs and generated API-token records are preserved during import. Exported configuration contains configured secrets and must be protected. -- Optional on-screen debug overlay is available on every Web UI route, can be changed through `/api/debug`, shows controller events plus live HTTP method/path/status timing and can include sanitized decrypted GREE request/response payloads. -- `/api/control-plan` exposes house state, current zone decisions, schedules, upcoming transitions and automation rules/time events. -- The dashboard renders the control plan as blocks with current targets/demand and upcoming actions. -- Home Assistant integration now also exposes whole-house/per-zone plan sensors, writable per-zone target-temperature `number` entities and per-zone enable `switch` entities via the restricted integration API. - -## 0.6.1 compile hotfix - -- Fixed `u8`/`u32` mismatches in communication-failure threshold handling without changing the persisted `Device.communication_failures` type or notification-settings schema. -- Removed the unused `AppState` parameter from Slack/Discord webhook delivery. -- Bumped application, Cargo lock root package, Home Assistant custom-component and PWA cache versions to `0.6.1`. - -## Validation performed in packaging environment - -- `node --check web/app.js`. -- Python syntax validation for the Home Assistant integration and helper scripts. -- Shell syntax validation for all `scripts/*.sh` files. -- JSON validation for EN/PL language packs, PWA manifest, Home Assistant manifest and HA translations. -- EN/PL UI translation-key parity and literal UI translation-reference validation. -- SQLite schema execution plus tiered compaction and event-log retention tests. -- SQLite archive-selection query tests for device, zone and Home Assistant histories. -- Configuration-clear behavior verified to preserve metric history, event logs and API tokens. -- Version consistency checked for Cargo package, Cargo lock root package, README and Home Assistant manifest. -- Archive ZIP integrity and SHA-256 manifest are verified during final packaging. - -## Rust compiler note - -The packaging environment does not contain `rustc`, `cargo` or `rustfmt`, so a Rust compile cannot be executed here. Rust unit tests cover unchanged-command filtering, history compaction, HA alias resolution, GREE outdoor fallback, night/native-Sleep decisions and combined standby command payloads; they cannot be executed in this packaging environment. The target LXC update/install scripts remain the authoritative compile gate and run: - -```bash -cargo test --all-targets -cargo build --release -``` - -The update script must complete those commands before replacing the running binary. - -## 2026-08-24 update - -Implemented from the current source archive: - -- Decimal thermostat input accepts both `.` and `,` in mobile/desktop UI via locale-independent parser and decimal keyboard hints. -- Notifications persisted in runtime settings with Pushover, Slack and Discord providers, test endpoint/button, two modes (`problems`, `important`), per-event cooldown, communication failure threshold and target-not-reached timeout. -- Device polling emits one offline anomaly after the configured consecutive-failure threshold and one recovery event when communication returns. -- Zone control tracks continuous demand and emits a `zone.target_timeout` anomaly once per demand episode when the target is not reached within the configured time. -- Automation dialog includes practical presets for high-temperature cooling, low-temperature heating, morning comfort and night power-off. -- Event log page has level/category filters and stronger visual separation by category/severity. -- Zone editor can copy thermostat tuning/profile settings from another zone without overwriting the destination zone identity, device assignment or HA entity. -- Language selector shows flags; theme selector shows compact theme icons. -- Home Assistant custom component includes `icon.png` and `logo.png` based on the existing project mark. -- New installs create a minimal environment file, keep `SIMULATE=false` and `AUTO_SEED=false`, and intentionally allow an empty application token. Existing env files remain untouched. -- Reverse-proxy support includes base-path routing, `X-Forwarded-Prefix`, prefix-aware UI/API/WebSocket/PWA assets and helper script health URLs. -- Removed permissive CORS and added basic browser security headers. Notification webhooks are HTTPS-only, host-restricted and do not follow redirects. -- Added `docs/SECURITY.md` and `docs/REVERSE_PROXY.md`. - -History review: the supplied counts (28,133 GREE, 1,889 zone, 5,626 HA samples) are modest for the existing SQLite design. The current schema already has time/entity indexes, history bucketing, query limits, retention and compaction, so no database migration was added solely for these counts. - -Validation performed in the build environment: - -- `node --check web/app.js` - passed. -- JSON parsing of language files and web manifest - passed. -- `bash -n scripts/install.sh` - passed. -- Home Assistant icon files generated and verified as 256x256 RGBA PNG. -- Rust compilation was not executed because this execution environment does not contain `cargo`/Rust toolchain. Backend changes were therefore statically reviewed but require the normal project CI/build host to run `cargo test`/`cargo build --release` before deployment. - - -## 0.6.2 thermostat copy hotfix - -- The destination zone identity is now stored independently from the form payload while editing. -- Copy-source choices exclude the currently edited zone. -- Copying thermostat settings explicitly preserves the destination zone ID, name, assigned device, sensor strategy/entity and enabled state. -- Zone updates use the independently tracked destination ID, preventing the source profile from being overwritten by a stale or mutated form ID. - - -## 0.6.3 house climate mode highlight - -- The selected whole-house climate mode (Cool / Heat / Off) now has a persistent illuminated border, accent fill and glowing status dot. -- The active mode is still derived from persisted `settings.house_mode`, so the highlight survives refreshes and reconnects. -- Mode buttons now expose `aria-pressed` for an explicit accessible selected state. - -## 0.6.4 Home Assistant zone thermostat naming - -- Zone climate entities now use a descriptive entity name, `Thermostat`. -- New zone entities are therefore created as `climate._thermostat` instead of falling back to ambiguous numeric suffixes such as `_2`. -- Existing zone climate registry entries are migrated on integration reload to the same scheme based on the zone's current name, fixing stale IDs left after zone renames (for example `climate.jan_2` for a zone currently named Igor). -- Migration is collision-safe: if the desired `climate._thermostat` ID is already occupied, the existing entity ID is retained and a warning is logged instead of overwriting another entity. - -## 0.6.5 house control, Home Assistant and LAN response time - -- Whole-house thermostat `off` is presented as **Do not control** and no longer continuously powers units down. Directly operated devices remain untouched, while a zone explicitly overridden to Heat/Cool can continue controlling its own unit. -- Added a separate persistent whole-house master-power state. **Turn all off** powers enabled units down and prevents zone/controller automations from restarting them without modifying the selected thermostat mode/profile; **Turn all on** re-enables master power and powers enabled units on. -- Whole-house mode and preset buttons use the same normal active fill as other controls; the obsolete activity-dot styling was removed. Uniform whole-house presets remain visibly selected, while mixed zone profiles show no false global selection. -- Home Assistant now exposes controller-level **Thermostat mode** and **Work profile** select entities plus an **All air conditioners** master-power switch, backed by dedicated restricted HA endpoints. -- Device state stores the latest successful GREE communication round-trip time. **LAN network -> Devices** displays it in milliseconds next to the IP address and protocol. -- PWA cache key advanced to `gree-controller-v065`. - - -## 0.6.6 synchronized whole-house activation - -- The global power panel is displayed above **House climate mode**. -- Selecting whole-house Cooling or Heating clears a previous global power-off and powers on all enabled units. -- Selecting Auto, Comfort, Sleep or Away as a whole-house profile does the same. -- House mode `off` / **Do not control** remains non-invasive and does not alter master power. -- The behavior is implemented in the shared backend endpoints, so Home Assistant house-mode/profile controls follow the same rules. - -## 0.6.6 climate groups, HA off-target and standalone simulation - -- Added persistent named climate groups based on thermostat zones, with independent group power, Heat/Cool/whole-house-follow mode and Auto/Comfort/Sleep/Away profile control. -- Added group controls directly below the global **All air conditioners** panel plus a dedicated **Groups** view for membership management. -- Whole-house **Turn all on**, Cooling/Heating and whole-house profile activation now re-enable all groups as part of their existing "all units" semantics; global power-off remains the authoritative master block. -- Automation actions can target either a direct device or a climate group and can apply group mode/power/profile changes. -- The control plan keeps a resolved thermostat target temperature while a zone, group or whole-house inherited mode is off, so Home Assistant zone climate entities no longer expose the configured target as `unknown` solely because control is disabled. -- Home Assistant documentation and zone attributes clarify that zone thermostat **Auto** means follow the controller's whole-house Heating/Cooling mode; the direct physical-device climate entity still uses native GREE Auto. -- The simulator can be opened as a standalone new-tab view, switched to browser full-screen mode, and filtered by individual unit or climate group. -- Configuration backup/import, bootstrap/WebSocket state, SQLite schema and API documentation include groups. - -Validation performed in this packaging environment: - -- `node --check web/app.js` and `node --check web/sw.js`. -- Home Assistant Python modules compiled with `python3 -m py_compile`. -- English/Polish translation packs parsed and checked for key parity and static UI references. -- HTML IDs and translation references checked for duplicates/missing keys. -- `INIT_SCHEMA` executed against an empty SQLite database and the new `climate_groups` table verified. -- Rust compilation cannot be executed here because the environment does not contain `cargo`, `rustc` or `rustfmt`; the backend changes were statically reviewed and still require the normal LXC/CI `cargo test --all-targets` and `cargo build --release` gate before deployment. - - -## 0.6.7 dashboard hierarchy - -- Reorganized the main dashboard into three levels: whole-house controls, climate-group controls, and secondary details. -- Global master power and whole-house climate mode/profile now share one panel instead of competing for separate dashboard space. -- Group controls remain directly below global control, use a compact horizontal strip, and omit duplicate mode/profile/member summary boxes on the dashboard while retaining full details on the Groups page. -- Control plan, quick thermostats and direct device controls remain available from the dashboard but are collapsed by default and can be expanded independently. -- Dashboard section badges show current group, zone and device counts. -- Mobile layout keeps global actions and expandable sections compact without removing any controls. -- PWA cache key advanced to `gree-controller-v067-dashboard` so the reorganized web assets replace cached v0.6.6 files after update. - -## 0.6.8 compact dashboard without horizontal scrolling - -- Removed the horizontally scrolling group strip from the main dashboard. -- Dashboard group cards now use a responsive wrapped grid and flow into additional rows. -- Group mode/profile controls use fixed grids on the dashboard, so they never require horizontal scrolling. -- Whole-house profile controls also wrap into a grid on narrow screens. -- Kept Control plan, Zones and Devices as collapsed disclosure panels. -- Reduced dashboard hero, metric, global-control, group-card and disclosure spacing for a denser layout without removing functionality. -- Advanced PWA cache key to `gree-controller-v068-dashboard-wrap`. - - -## 0.6.9 dashboard fit, compact summary and group-aware control plan - -- Reworked dashboard Quick Thermostats so the target-temperature control occupies its own row and measured/device-target temperatures sit below it; labels no longer collide or clip in medium-width cards. -- Kept the quick-thermostat list as a wrapped responsive grid with no horizontal scrolling. -- Compressed the Home / Comfort under control summary into one short card; average temperature and small online/active/demand counters now share the same row instead of consuming a separate large hero plus metrics row. -- Reduced dashboard control-plan blocks to compact simulator-style cards. -- Added explicit climate-group cards to What happens next, combining member demand and upcoming member-zone events. -- Zone plan cards identify their climate group membership, and automation plan rows preserve group targets. -- Advanced the PWA cache key to `gree-controller-v069-dashboard-fit`. - - -## 0.7.0 UI cleanup and scheduler/automation integrity audit - -- Removed directional arrow glyphs from Swing controls on dashboard and device cards. -- Removed the divider above the unit feature block and tightened desktop quick-mode/fan controls so all buttons fit without horizontal scrolling. -- Allowed the detailed zone quick-change hint to wrap naturally on desktop. -- Added an explicit Mode policy hint: keep Follow house mode when the zone should inherit the global GREE Controller mode; fixed Cooling/Heating are intentional overrides. -- Fixed the Always-comfort schedule template to represent a true 24-hour window without a one-minute gap. -- Added overlap detection for enabled schedules, including overnight windows, and deterministic handling for legacy overlapping rows. -- Fixed the Workday template Sunday-to-Monday handoff so Sunday Sleep ends at 06:30 before Monday Morning starts. -- Manual zone/profile overrides now expire at the actual next weekly schedule transition; if none exists, they remain active until explicitly cleared instead of expiring after an arbitrary fallback timeout. -- Editing/deleting/applying schedule templates refreshes the active manual-override boundary. -- Time automations are limited to one execution per local minute even with the minimum 30-second cooldown; failed actions now also observe cooldown to avoid retry/log storms. -- Temperature automations ignore stale readings from disabled/offline trigger devices. -- Direct-device automations cannot wake a unit blocked by a disabled climate group. -- Group power-on respects other disabled overlapping groups; group power-off remains authoritative. -- Automation creation/import validates target/trigger references, empty actions and unsupported group-action fields. -- Device/zone deletion now protects automation/group references and removes orphaned schedules. A physical device can be assigned to only one thermostat zone. -- Control-plan event timestamps are normalized to minute boundaries; time-event preview covers DST-length local days. -- Added Rust unit-test cases for overnight/full-day schedules, overlap detection, weekly boundary lookup, Workday weekend handoff and one-fire-per-minute time automations. - -Rust compilation/tests still require a host with the Rust toolchain (`cargo`/`rustc`); this build environment does not provide it. Static frontend, JSON, Python/Home Assistant, archive and manifest validation are performed below. - -Validation for the 0.7.0 package: - -- `node --check` passed for `web/app.js` and `web/sw.js`. -- PL/EN JSON and Home Assistant manifest parsed successfully; PL/EN translation key sets are identical (578 keys each). -- Home Assistant Python modules passed `python3 -m py_compile`. -- `web/index.html` parsed with Python's HTML parser; CSS braces are balanced. -- No Swing arrow glyph remains in web controls, the device feature divider rule is removed, and the detailed-zone quick hint has an explicit wrapping rule. -- All built-in schedule templates were exercised against the weekly minute-mask model: no overlaps; Family/Child/Bedroom/Always cover the full week, and Workday's intentional uncovered period falls back to the normal Comfort profile. -- SQLite `INIT_SCHEMA` and device-delete orphan-schedule cleanup SQL were executed successfully against temporary in-memory databases. -- Rust source delimiter/static checks passed. Rust compile/test execution is unavailable here because no Rust toolchain is installed. - - -## 0.7.1 quick-control visual consistency - -- Refactored dashboard-only thermostat/device controls to semantic `quick-control-card` and `quick-control-row-*` classes. -- Quick thermostat preset/mode controls and quick manual HVAC/fan/function controls now share the same compact height, spacing, typography and fixed responsive grids. -- Removed the superseded `#dashboardDevices` desktop-only size override; full Devices/Zones views remain unchanged. -- Changed the compact inherited-mode label from `Dom/House` to `Globalny/Global`, while retaining the internal `house` value for API/backward compatibility. -- Changed `Sen teraz/Sleep now` to `Sen/Sleep`. - -Validation for the 0.7.1 package: - -- `node --check` passed for `web/app.js` and `web/sw.js`. -- PL/EN JSON parsed successfully and translation key sets are identical (574 keys each). -- Home Assistant manifest parsed successfully and Home Assistant Python modules passed `python3 -m py_compile`. -- `web/index.html` parsed with Python's HTML parser and CSS brace balance passed. -- Static assertions confirm the compact classes are attached only to dashboard quick cards (`detailed == false`), while full Zone/Device cards keep their existing classes and sizes. -- Quick controls use explicit 3/4/5-column grids with no horizontal overflow behavior; all compact option buttons share the same 28 px minimum height, 9 px typography and 4 px gap. -- User-facing inherited-mode labels are `Globalny/Global`; the internal/API value remains `house`, preserving backward compatibility. -- No thermostat, schedule, automation, group, database or device-control logic was changed in 0.7.1. - -## 0.7.2 zone configuration separation - -- Reworked the Zones list into read-only status + configuration cards. -- Removed live temperature, preset, mode and enable/disable controls from the Zones list. -- Added navigation from a zone configuration card to its matching Dashboard quick thermostat. -- Kept backend zone-control endpoints and thermostat logic unchanged. -- Kept the legacy `setpoint` field in form submission as a hidden compatibility value while profile temperatures remain the explicit configuration surface. - -## 0.7.2 packaging hotfix - -- Corrected an invalid `Cargo.lock` entry that labeled the `tokio-util` package as `0.7.29` while retaining the checksum and dependency resolution for `0.7.19`. -- Restored `tokio-util = 0.7.19`, which satisfies `tower-http 0.6.11` (`^0.7`) and is present in the crates.io index used by the target LXC. -- No application source, database schema, scheduler, automation, device-control or Web UI logic changed in this hotfix. - - - -## 0.7.3 Home Assistant climate-group support - -- Added restricted HA group discovery and group-control endpoints. -- Added one Home Assistant device per configured climate group. -- Added group Power switch, Thermostat mode select, Work profile select and Control plan sensor. -- Group reporting derives common mode/profile from member-zone configuration and reports mixed states without coercing them. -- Group plan attributes include members, online unit count, average current temperature, demand count and upcoming zone schedule events. -- Existing group-control engine remains the single command path, including global-master-power and overlapping-disabled-group protections. - -### 0.7.3 validation - -- `python3 -m py_compile home-assistant/custom_components/gree_controller/*.py` passed. -- Home Assistant manifest and PL/EN translation JSON parsed successfully. -- `Cargo.toml` parses successfully and both Cargo package metadata and HA manifest report `0.7.3`. -- `Cargo.lock` keeps the packaging hotfix at `tokio-util = 0.7.19`. -- Static assertions confirm the restricted HA router exposes group discovery/control, the API client polls groups, and select/switch/sensor group entities are registered. -- `node --check web/app.js` and `node --check web/sw.js` passed. -- `bash -n scripts/*.sh` passed. -- HTML parsing and CSS brace-balance checks passed. -- The validation environment does not provide `cargo`/`rustc`, so the Rust test/build step must run on the target host/LXC (the normal `scripts/update.sh` performs `cargo test --all-targets` before installation). - -## 2026-08-25 disabled-zone and standby-command hotfix - -- Disabled thermostat zones are excluded from normal house, group-loop and direct-device automation ownership while sensor polling/history continue normally. -- Disabling an active zone now sends one immediate power-off command to its assigned unit. Later manual operation remains possible through the technical Devices control path. -- Whole-house ON only powers devices assigned to enabled thermostat zones; unassigned devices and devices belonging to disabled zones are not started. -- Whole-house OFF now cascades group power gates to OFF and powers off all enabled physical devices. Whole-house ON restores group gates but still respects disabled zones. -- Restricted Home Assistant direct-device commands are rejected for devices assigned to disabled zones; zone/group/house endpoints keep their managed semantics. -- Dashboard direct-device controls are locked for devices assigned to disabled zones and point the operator to the Devices view for explicit technical/manual control. -- Smart Quiet now follows demand transitions instead of being reasserted throughout steady standby. Outside the night window it no longer fights Smart Fan by toggling Quiet off/on on successive cycles. -- Standby Low fan is sent on a relevant transition/core thermostat change rather than retried forever when firmware reports a different fan state, preventing periodic command/beep loops with unchanged room temperature and setpoint. -- Added regression assertions for steady-standby Smart Quiet behavior and night-mode/Smart-Fan interaction. -- PWA cache key changed so browsers fetch the corrected frontend and language strings after deployment. - -Validation in this packaging environment: - -- `node --check web/app.js` and `node --check web/sw.js` passed. -- PL/EN application language JSON parsed successfully and translation-key parity passed. -- `web/index.html` parsed successfully with Python's HTML parser. -- Home Assistant Python modules and JSON manifests/translations passed syntax/parse checks. -- Shell scripts passed `bash -n`. -- Rust source was statically reviewed and delimiter-checked. This environment does not provide `cargo`, `rustc` or `rustfmt`; the normal target update path must run `cargo test --all-targets` and `cargo build --release` before installation. - - -## 0.7.4 physical/manual takeover - -- Added persistent per-zone device manual override state with source fields, start time and optional next-schedule expiry. -- Polling detects external power/mode/setpoint/fan changes relative to the last controller-known state and suspends automatic correction for that zone. -- Known standby Low-to-Auto fan normalization is ignored to avoid false remote-control detection. -- Technical climate commands from the Devices endpoint enter the same takeover; light-only/non-thermostat feature changes do not. -- Manual takeover has priority over zone modulation, climate groups and direct-device automations. Whole-house power-off is the explicit exception and clears all takeovers. -- Added Web UI Manual control / remote state and Resume automation action, plus control-plan fields for integrations/simulation. -- Added regression tests for external climate-state detection, Low-to-Auto suppression and takeover reset state. - -### 0.7.4 validation - -- `node --check` passed for `web/app.js` and `web/sw.js`. -- PL/EN language packs parse successfully, have identical key sets (586 keys each), and all UI translation references resolve. -- Home Assistant Python modules and helper Python scripts pass `py_compile`; shell scripts pass `bash -n`. -- Cargo metadata parses from `Cargo.toml`; package, Cargo lock root package and Home Assistant manifest report `0.7.4`. -- `web/index.html` parses successfully and CSS braces are balanced. -- Rust source delimiter/static invariant checks pass, including poll detection, group/automation guards, whole-house override clear and technical manual-command routing. -- The packaging environment has no `cargo`, `rustc` or `rustfmt`; the target update path remains the compile gate and must run `cargo test --all-targets` and `cargo build --release` before replacing the running binary. - -## 0.7.5 UI polish - -- Added explicit active styling for climate-group **ON/OFF** buttons, matching the existing active mode/profile treatment. -- Reworded simulator demand summary to **Zones with demand** / **Strefy z zapotrzebowaniem**. -- Bumped application and Home Assistant integration version to `0.7.5` and rotated the PWA service-worker cache key. -- Validation: `node --check` passed for the web scripts, PL/EN language packs parse with identical 587-key sets, version declarations are consistent and the package SHA-256 manifest verifies. -- `cargo check` remains unavailable in this environment because the Rust toolchain is not installed. - - -## 2026-08-26 manual takeover, per-unit quick power and restart safety - -Implemented against the supplied v0.7.5 source archive: - -- Manual/remote takeover now stores the pre-takeover physical climate state in the persisted zone payload. -- If the user restores that operational state, the takeover clears automatically. When the previous state was OFF, returning to OFF is sufficient even if the remote retained a different dormant target/mode, so a stale **Resume automation** prompt is not shown. -- If the manual state is not restored, the existing next-schedule-boundary expiry remains unchanged; zones without a future transition still require an explicit resume. -- Dashboard **Quick thermostats** now include per-unit ON/OFF controls. They intentionally use manual/remote semantics, affect only that physical unit and do not enable its climate group. -- Cooling controlled by a valid external/combined room sensor now applies a small pre-rounding demand bias so the unit selects the next lower whole-degree target (normally 0.5-1.0 C below the room target). The bias is disabled during GREE fallback/discrepancy fallback and is not added on top of a larger outdoor-assist correction. -- Startup thermostat/schedule/automation command emission is gated until one complete poll attempt of all enabled devices finishes. This prevents stale persisted device state from producing a restart-only command while leaving manual API control available. -- `/api/health`, bootstrap system data and `/api/system/info` expose `control_ready` for startup diagnostics. -- Advanced the PWA cache key to `gree-controller-v075-zone-manual-power-restart` so changed dashboard assets are refreshed. - -Validation performed in this packaging environment: - -- `node --check web/app.js` - passed. -- `node --check web/sw.js` - passed. -- EN/PL JSON parsing and translation-key parity - passed (590/590 keys). -- Static `tr(...)`/`data-i18n` references in the Web UI - all resolved. -- `web/manifest.webmanifest` JSON parsing - passed. -- `bash -n scripts/*.sh` - passed. -- Home Assistant Python modules and helper Python scripts compiled with `python3 -m py_compile` - passed. -- All direct `Zone { ... }` constructors were updated for the new persisted baseline field. - -Rust compiler note: this packaging environment still does not provide `cargo`, `rustc` or `rustfmt`, so the Rust unit tests and release build cannot be executed here. The normal deployment/update compile gate must still run `cargo test --all-targets` and `cargo build --release` before replacing the running binary. - -## 2026-08-26 Home Assistant state-bounce fix - -- Added bounded post-command GREE status settling retries (0/150/350/650 ms) so an acknowledged write is not immediately contradicted by a firmware status frame that still contains the pre-command state. -- Added a three-second Home Assistant pending-command guard for physical-unit climate and optional-feature switches. Fresh command intent survives overlapping/stale coordinator refreshes, then automatically yields to factual controller polling. -- Kept the guard intentionally short and non-persistent; it does not change restart behavior or permanently mask a failed command. - - -## 2026-08-26 v0.7.7 quick-control desktop label fit - -- Reproduced the dashboard quick-device layout issue from the supplied screenshot: five equal columns left too little usable width after button padding for Polish `Chłodzenie`/`Osuszanie`. -- Rebalanced only the five quick-device mode columns and reduced their horizontal padding from 5 px to 3 px. This keeps all five labels on one line without reducing the shared 9 px control font. -- Verified the Home Assistant integration already exposes every controller zone as an independent climate thermostat with `TURN_ON`/`TURN_OFF`, plus a separate per-zone Enabled switch. Turning on a zone thermostat sends only `enabled: true` for that zone; it does not power the whole group. Physical indoor units also remain independently controllable through their own climate entities. -- Bumped application/Home Assistant package metadata to `0.7.7` and rotated the PWA cache key. - - -## 2026-08-26 v0.7.8 zone status wording and quick unit power - -- Replaced the misleading thermostat runtime labels **Potrzeba komfortu** / **Komfort / standby inwertera** with **Zapotrzebowanie aktywne** / **Cel osiągnięty**. The Comfort wording remains reserved for the actual Comfort preset/profile. Runtime status also reports **Oczekiwanie** when thermostat control is paused by mode and **Brak pomiaru** when no valid control temperature is available, so **Cel osiągnięty** is not used as a generic idle state. -- Simplified per-unit manual power in Dashboard -> Quick thermostats: removed the redundant **Jednostka · pilot** caption and the separate ON/OFF pair. A single contextual **Włącz jednostkę** / **Wyłącz jednostkę** button now directly controls only that physical unit with the existing manual-takeover semantics. -- Bumped package/Home Assistant metadata to `0.7.8` and rotated the PWA cache key. - - -## 2026-08-27 v0.7.9 zone profile and Home Assistant fixes - -- Explicit quick-zone target/mode/profile/enable actions now refresh the runtime profile target immediately and wake the regulator instead of waiting for the next fixed thermostat interval. -- Manual/remote takeover no longer exposes a physical standby target (for example 25 C) as the zone thermostat target. The thermostat profile target and physical `device_setpoint` are reported separately. -- Control-plan `enabled` is now the configured per-zone switch; new `effective_enabled` reports disabled-group gating without making the Home Assistant zone switch bounce to Off. -- Home Assistant zone commands have a short pending-state guard against stale overlapping refreshes. -- Zone climate entities support Auto/Comfort/Sleep/Away presets and each zone device has a dedicated **Work profile** select. -- Added a focused Rust unit test for immediate manual-profile target refresh. - -Validation for this package is recorded after the final archive/manifest checks. Rust compile/test still requires the normal target/CI host because this packaging environment has no Rust toolchain. - - -## 2026-08-27 v0.7.10 local quick thermostat and false takeover fix - -- Added persisted `local_thermostat_power` ownership with three distinct states: normal group/schedule ownership (`null`), locally forced thermostat ON (`true`) and locally forced thermostat OFF (`false`). -- Dashboard quick ON/OFF uses local thermostat semantics and never calls the direct/manual-device command path. -- A locally active thermostat bypasses disabled climate-group power gates while retaining the normal sensor, hysteresis, profile/target, Smart Fan and GREE setpoint-modulation logic. -- **Resume automation** for a local thermostat clears local ownership and returns the zone to group/schedule control. The existing device takeover Resume action remains separate. -- Direct device commands and genuine remote/pilot changes still create `device_manual_override`; local thermostat ownership does not. -- Added a bounded pending controller-command record for GREE climate fields when post-command status has not settled. A later poll matching that expected state is no longer misclassified as remote control, fixing false **Manual control / remote** after group ON and similar controller-originated transitions. -- Direct device automations are suppressed while local thermostat ownership is active so they cannot fight the local regulator. -- Home Assistant zone climate ON/OFF now maps to local thermostat power; the separate Enabled switch remains the technical zone enable. -- Rotated the PWA cache key and advanced package/Home Assistant metadata to `0.7.10`. - -## Final validation for v0.7.10 - -- `node --check` passed for all Web UI JavaScript files. -- Home Assistant modules, helper scripts and `make_zip.py` passed `python3 -m py_compile`. -- All JSON files parsed successfully; Polish and English language packs have identical 598-key sets. -- All shell scripts passed `bash -n`; `web/index.html` parsed successfully and CSS braces are balanced. -- Cargo package, Home Assistant manifest, README and PWA cache metadata consistently report version `0.7.10`. -- Rust source delimiter/static invariant checks passed, including the new regulator wake-up and control-plan fields. -- `FILE_MANIFEST.sha256` is regenerated from the final tree and verified before packaging; the resulting ZIP is tested with `unzip -t`. -- This environment does not provide `cargo`, `rustc` or `rustfmt`, so the normal target/CI update path must still run `cargo test --all-targets` and `cargo build --release` before replacing the running binary. - - -## 2026-08-27 v0.7.11 cleanup and compile fix - -- Removed the obsolete `/api/zones/{id}/manual-power` compatibility route, request structure and handler. The only zone quick-power API is now `POST /api/zones/{id}/control` with `power:true/false`. -- Renamed Web UI local-thermostat power internals so they no longer use misleading `manual-power` terminology. -- Fixed Rust error E0505 in `local_thermostat_ownership_blocks_direct_automation`: the test now passes a borrowed one-element slice with `std::slice::from_ref(&zone)` instead of moving `zone` while borrowing `zone.device_id`. -- Advanced package/Home Assistant/PWA metadata to `0.7.11`. - -## Final validation for v0.7.11 - -- `node --check` passed for Web UI JavaScript files. -- Home Assistant modules, helper scripts and `make_zip.py` passed `python3 -m py_compile`. -- JSON files parsed successfully; Polish and English language packs have identical 602-key sets. -- All shell scripts passed `bash -n`; `web/index.html` parsed successfully and CSS braces are balanced. -- Runtime source trees (`src`, `web`, `home-assistant`, `scripts`) contain no legacy `manual-power` route, handler, symbol or CSS naming. -- Cargo package and Home Assistant manifest report version `0.7.11`; PWA cache metadata was rotated for this release. -- The reported Rust E0505 test case was corrected with borrow-only `std::slice::from_ref(&zone)` calls. -- This packaging environment does not provide `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment. -## 2026-08-27 v0.7.12 timed local-thermostat resume - -- Added persisted `local_thermostat_resume_at` to each zone. -- Local Quick Thermostat ON has no timeout; local OFF powers the unit down immediately and schedules automatic automation hand-back after exactly 15 minutes. -- The control loop now sleeps only until the earlier of its normal interval or the nearest local-resume deadline, so the 15-minute hand-back is not delayed by a long zone interval and remains restart-safe. -- Expiry clears local power ownership and quick preset/setpoint overrides, then re-evaluates the current group, house mode, schedule, temperature and hysteresis. It never restores a stale physical ON/OFF snapshot. -- Physical/pilot `device_manual_override` is intentionally untouched by the local timer. -- Web UI displays a live `MM:SS` countdown and **Resume now** during the local-OFF window. -- Persisted 0.7.10/0.7.11 local-OFF records without a deadline receive a fresh 15-minute deadline on first control cycle after upgrade. -- Home Assistant zone attributes expose `local_thermostat_resume_at`. -- Added a unit test that verifies local hand-back clears local quick state without clearing a real physical-device takeover. - -## Final validation for v0.7.12 - -- Web JavaScript passes `node --check`. -- Home Assistant modules and helper scripts pass `python3 -m py_compile`. -- JSON and TOML metadata parse successfully; Polish and English translation packs have identical key sets. -- Shell scripts pass `bash -n`; HTML parses and CSS braces are balanced. -- Source references for `local_thermostat_resume_at` are consistent across model, API, engine, Web UI, control-plan output and Home Assistant attributes. -- Version metadata is aligned at `0.7.12` and the PWA cache key is rotated. -- This packaging environment still has no `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment. - - -## 2026-08-27 v0.7.13 quick-thermostat race protection and frame counters - -- Quick Thermostat changes from Web/Home Assistant are serialized with the same per-device operation lock used by polling/manual-takeover detection. -- Controller-originated climate changes retain a bounded settling history of requested and pre-command states so delayed/out-of-order GREE status frames do not falsely trigger physical/pilot takeover. -- Dashboard Quick Thermostat **Global** is disabled whenever the global house mode is neither Heating nor Cooling. -- Settings exposes live total and per-device UDP receive counters for configured GREE units via the existing WebSocket stream. -- The Home Assistant zone `climate` entity remains the supported path for controlling one thermostat independently. - -## Final validation for v0.7.13 - -- `node --check` passed for `web/app.js` and `web/sw.js`. -- All JSON files parsed successfully; Polish and English language packs have identical 610-key sets. -- Home Assistant Python modules and helper Python scripts pass `py_compile`; shell scripts pass `bash -n`. -- `web/index.html` parses successfully and CSS brace balance passes. -- Cargo package, Home Assistant manifest, README and PWA cache metadata are aligned at `0.7.13`. -- The final SHA-256 file manifest and ZIP integrity are verified during packaging. -- This packaging environment does not provide `cargo`, `rustc` or `rustfmt`; target/CI must run `cargo test --all-targets` and `cargo build --release` before deployment. diff --git a/Cargo.lock b/Cargo.lock index 3feb648..4e17093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.3" +version = "0.8.4" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index defd245..d5cb483 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.3" +version = "0.8.4" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 1d0c03e..692d767 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -15f85a3e81f0c1078faec87f72e54bfa1330bbe230b69486316aafef5c556a5d ./BUILD_REPORT.md -42cf2fd754c44be8beb22b6b63df94b1f633bd6e54256790778f9f732c95652f ./Cargo.lock -ed2db5ae8ae02b43e603264b9dc771e1c7f6e0e3f522337d9dad8c462f80590c ./Cargo.toml +23209d6e3c3ba681d5e414aa85840b019c5e4c4ac3fe86afab8590eccbfb8315 ./BUILD_REPORT.md +450f637f06e0ca0e516bd144c3000ad707db51a343a095994bfc657ffcdf130a ./Cargo.lock +a9d33234e320683b0d6413dfa9098435d17213b8058639a849cc64a5f0ca25fa ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -dd40e740be80cafc6905d82ad061b0a3bf5ed9b26687b31a07d69ec8b3c0c066 ./README.md +87c67cc28db5426d89cdbd4369a05016f0b058331273688bc52e286088b1b5dc ./README.md 91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs -2562170218783698d32be10cafa0758a27bfb4217b32bdecad4c9ac7d9b0e7eb ./docs/API.md +d458a3b1ed381c885d7c4b7e38f306f3e66838d27480551436f1e340995ea6e2 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md @@ -24,7 +24,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -17f0223d48c8db0a1e7cd58042d8fa3d118f2bee432bccfaac27f62c0597a3e5 ./home-assistant/custom_components/gree_controller/manifest.json +59c5239f76835e0a1c8326b2525f4cc1528a1a3021b7e197a216308e2b1421e5 ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py @@ -33,8 +33,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -e02abc97898883ebdcc84bf3908c198d9e945ec350f3c749b4187327a8d35b7d ./lang/en.json -89073a413f651fdc1ec2492b9cc860147259faccf08444bf9b6878e4f33bd260 ./lang/pl.json +ca87682179c70391e85348482a9ad9acc7f4b0f86063b957da6dda03849a3993 ./lang/en.json +2d8c3a6fcebf62a6b551a109c05e8a91fe7a1fd099d24eb990d9dbc245b869df ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -47,15 +47,15 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -3d84f4606da4a1b8505b2e9b619320520d27058299b4561a52b564de06b0ad18 ./src/api.rs +e1153953e3217380ed4f4559e85236d3e26d6dd6168683a721f053252426c454 ./src/api.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -22e42c9f9727deccc41a901d2aba148aa06ef6b7d262956ba9ed54f50590a2ce ./src/engine.rs +ffb8abf8e7d092c01e75940744a747debcb2ecc35d417f73bb4c094842ed1607 ./src/engine.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs -054dda2e93faf27028a3117bc4440da9f54b67819de2e0de744e487e496b5899 ./src/models.rs +8dd681a96aa4f962b7f7dde80bed262e228fa93806e94f635eee8c3840f6c8be ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs @@ -63,10 +63,10 @@ a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs 2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -3d0eb4ffc1877165b4a95bf4fd42676643878358ef0878324228b44f0a6954ef ./web/app.js +18de577ee8a978ba691f3ce1ada5aa58f0229fc9b1e5015cbc5726c284bfa85d ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -97b0df94611803c4fe70559b32c961600526218b079a372456b38144c7a61ce4 ./web/index.html +ee64c273b334c2d0f9747408b6142a8d506021cbf2107178c2213ca4e4a17c8c ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest 6aa06f52e4c6391a43016a6a585b7422e3de319744db40a9b233beb27e57412d ./web/styles.css -ca7c496ad22a8fa7625f99b65ffec8881d4edae613bea58746f18113f2fc62b5 ./web/sw.js +c93bacaf417610558881bdfd0a3eae324c5d783a0a34c1b7527a145abb469ce1 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 7895495..b2aad56 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.8.3**. +Current version: **0.8.4**. ## Highlights @@ -47,7 +47,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do On Debian, Ubuntu or an LXC container: ```bash -unzip gree-controller-v0.8.3.zip +unzip gree-controller-v0.8.4.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -480,6 +480,15 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C +## 0.8.4 Scheduled Temporary Quick Thermostat start and ownership hardening + +- Temporary Quick Thermostat can start immediately, after a configurable delay, or at a specific local date/time. +- Delayed sessions do not take thermostat ownership early: normal schedules and automations continue until the effective start instant. +- Duration, safety timeout and next-schedule-boundary finish rules are calculated from the effective start, not from the time the session is configured. +- Active temporary ownership keeps its requested target across schedule transitions and schedule edits; direct device automations and group climate changes cannot overwrite it while it is active. +- A local Quick Thermostat can run using the zone's local Heat/Cool mode even when the inherited house climate mode is set to **Do not control**; the separate whole-house master power remains authoritative. +- Cancelling a not-yet-started session only removes that session and does not clear unrelated quick-control state. + ## 0.8.3 Temporary Quick Thermostat sessions - Adds a separate persisted timed Quick Thermostat session without reusing the existing 15-minute local-OFF hand-back timer. diff --git a/docs/API.md b/docs/API.md index 6d48843..6b05f54 100644 --- a/docs/API.md +++ b/docs/API.md @@ -136,12 +136,20 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl # Run Quick Thermostat for 90 minutes, then hand control back to automation. curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"temporary_quick_thermostat":{"finish_kind":"duration","duration_minutes":90,"target_temperature":23.0}}' + -d '{"temporary_quick_thermostat":{"start_kind":"now","finish_kind":"duration","duration_minutes":90,"target_temperature":23.0}}' + +# Schedule Quick Thermostat to start in 45 minutes and run for two hours. +curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"temporary_quick_thermostat":{"start_kind":"delay","start_delay_minutes":45,"finish_kind":"duration","duration_minutes":120,"target_temperature":23.0}}' + +# Schedule an absolute start. All timestamps are ISO-8601 and are stored as UTC internally. +curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"temporary_quick_thermostat":{"start_kind":"at","start_at":"2026-08-28T18:30:00+02:00","finish_kind":"until","until":"2026-08-28T22:00:00+02:00","target_temperature":23.0}}' # Hold 23 C continuously for one hour. Leaving the +/-0.3 C band resets the hold timer. # The optional safety limit hands control back after four hours even if the condition never settles. curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"temporary_quick_thermostat":{"finish_kind":"temperature_stable","target_temperature":23.0,"temperature_operator":"within","tolerance_c":0.3,"hold_minutes":60,"max_duration_minutes":240}}' + -d '{"temporary_quick_thermostat":{"start_kind":"now","finish_kind":"temperature_stable","target_temperature":23.0,"temperature_operator":"within","tolerance_c":0.3,"hold_minutes":60,"max_duration_minutes":240}}' # Cancel only the temporary Quick Thermostat session and return to the previous automation state. curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ @@ -152,7 +160,7 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl `power:true/false` on `/api/zones/{id}/control` is a local quick-thermostat power override, not a physical/manual command. `true` lets that zone run through the full thermostat even when its climate group is off and cancels any pending local hand-back timer. Every accepted `false` action turns only that zone off immediately and creates a fresh backend-owned `local_thermostat_resume_at` 15 minutes in the future. When that deadline is reached, the controller clears local quick ownership plus its temporary quick preset/setpoint and recalculates the current group/schedule/house-mode state instead of restoring an old physical ON/OFF snapshot. `clear_local_thermostat_override:true` performs that hand-back immediately. Direct/pilot-style control remains `/api/devices/{id}/command`. -`temporary_quick_thermostat` is a separate persisted session and does **not** reuse `local_thermostat_resume_at`. Supported `finish_kind` values are `duration`, `until`, `temperature_reached`, `temperature_stable`, and `schedule_boundary`. Temperature rules support `within`, `at_or_below`, and `at_or_above`, a tolerance, an optional safety deadline, and for `temperature_stable` a continuous hold duration. If the zone automation was disabled before the session, the session temporarily enables thermostat control and restores the disabled state when it finishes. +`temporary_quick_thermostat` is a separate persisted session and does **not** reuse `local_thermostat_resume_at`. `start_kind` accepts `now`, `delay`, or `at`; delayed sessions keep normal schedules and automations active until `started_at`, then acquire local thermostat ownership. Supported `finish_kind` values are `duration`, `until`, `temperature_reached`, `temperature_stable`, and `schedule_boundary`. Durations and safety limits begin at the effective start, not when a delayed session is created. `schedule_boundary` resolves the first schedule transition after the effective start. Temperature rules support `within`, `at_or_below`, and `at_or_above`, a tolerance, an optional safety deadline, and for `temperature_stable` a continuous hold duration. While active, the temporary target has priority over schedule transitions and normal device/group automations; those sources resume after hand-back. If the zone automation was disabled before the session, the session temporarily enables thermostat control and restores the disabled state when it finishes. ### Physical/manual device takeover diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index c96d5e1..2b65879 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.3", + "version": "0.8.4", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 9b27c1c..1954571 100644 --- a/lang/en.json +++ b/lang/en.json @@ -627,8 +627,15 @@ "zones.quickThermostat": "Quick thermostat", "zones.temporaryTitle": "Temporary mode", "zones.temporaryActive": "Temporary Quick Thermostat active", + "zones.temporaryScheduled": "Temporary Quick Thermostat scheduled", "zones.temporaryStop": "Finish and return to automation", "zones.temporaryTarget": "Quick thermostat temperature", + "zones.temporaryStartRule": "When to start", + "zones.temporaryStartNow": "Immediately", + "zones.temporaryStartDelay": "After a delay", + "zones.temporaryStartAt": "At a specific date and time", + "zones.temporaryStartDelayMinutes": "Start in (min)", + "zones.temporaryStartAtDate": "Start at", "zones.temporaryFinishRule": "When to return to automation", "zones.temporaryDuration": "After a duration", "zones.temporaryUntil": "At a specific date and time", @@ -659,9 +666,14 @@ "zones.temporaryWaitingReached": "Waiting for condition: {condition}.", "zones.temporaryUntilScheduleStatus": "Until the next schedule transition", "zones.temporaryTimeStatus": "Until automatic return to automation", + "zones.temporaryScheduledStatus": "Scheduled start: {time}. Normal automation continues unchanged until then.", + "zones.temporaryStartingStatus": "Starting Quick Thermostat…", "zones.temporaryStopped": "Temporary mode finished — automation resumed control", "zones.temporaryStarted": "Temporary Quick Thermostat started", + "zones.temporaryScheduledToast": "Temporary Quick Thermostat has been scheduled", + "zones.temporaryInvalidStartAt": "Enter a valid start date and time", "zones.temporaryInvalidUntil": "Enter a valid finish date and time", + "zones.temporaryEndAfterStart": "The finish time must be after Quick Thermostat starts", "zones.automationToggleHint": "Enables or disables thermostat automation for this zone. This is not temporary control." } } diff --git a/lang/pl.json b/lang/pl.json index b758b86..6cd0093 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -627,8 +627,15 @@ "zones.quickThermostat": "Szybki termostat", "zones.temporaryTitle": "Tryb czasowy", "zones.temporaryActive": "Aktywny szybki termostat", + "zones.temporaryScheduled": "Zaplanowany szybki termostat", "zones.temporaryStop": "Zakończ i wróć do automatyki", "zones.temporaryTarget": "Temperatura szybkiego termostatu", + "zones.temporaryStartRule": "Kiedy uruchomić", + "zones.temporaryStartNow": "Od razu", + "zones.temporaryStartDelay": "Z opóźnieniem", + "zones.temporaryStartAt": "O konkretnej dacie i godzinie", + "zones.temporaryStartDelayMinutes": "Uruchom za (min)", + "zones.temporaryStartAtDate": "Uruchom o", "zones.temporaryFinishRule": "Kiedy wrócić do automatyki", "zones.temporaryDuration": "Po określonym czasie", "zones.temporaryUntil": "O konkretnej dacie i godzinie", @@ -659,9 +666,14 @@ "zones.temporaryWaitingReached": "Oczekiwanie na spełnienie: {condition}.", "zones.temporaryUntilScheduleStatus": "Do następnej zmiany harmonogramu", "zones.temporaryTimeStatus": "Do automatycznego powrotu do automatyki", + "zones.temporaryScheduledStatus": "Zaplanowany start: {time}. Do tego czasu normalna automatyka działa bez zmian.", + "zones.temporaryStartingStatus": "Uruchamianie szybkiego termostatu…", "zones.temporaryStopped": "Tryb czasowy zakończony — automatyka przejęła sterowanie", "zones.temporaryStarted": "Tryb czasowy szybkiego termostatu uruchomiony", + "zones.temporaryScheduledToast": "Tryb czasowy szybkiego termostatu został zaplanowany", + "zones.temporaryInvalidStartAt": "Podaj prawidłową datę i godzinę uruchomienia", "zones.temporaryInvalidUntil": "Podaj prawidłową datę i godzinę zakończenia", + "zones.temporaryEndAfterStart": "Czas zakończenia musi przypadać po uruchomieniu szybkiego termostatu", "zones.automationToggleHint": "Włącza lub wyłącza automatykę termostatu tej strefy. Nie jest to chwilowe sterowanie." } } diff --git a/src/api.rs b/src/api.rs index cb9bd07..1319dfa 100644 --- a/src/api.rs +++ b/src/api.rs @@ -681,10 +681,37 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl engine::reset_local_thermostat_override(&mut zone); } if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() { - engine::reset_local_thermostat_override(&mut zone); + if zone.local_thermostat_power == Some(true) { + engine::reset_local_thermostat_override(&mut zone); + } else { + // Cancelling a delayed session before it starts must not erase unrelated + // quick preset/setpoint state that automation may be using in the meantime. + zone.temporary_quick_thermostat = None; + } } if let Some(request) = patch.temporary_quick_thermostat.as_ref() { let now = Utc::now(); + let start_kind = request.start_kind.as_str(); + if !matches!(start_kind, "now" | "delay" | "at") { + return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into())); + } + let started_at = match start_kind { + "now" => now.clone(), + "delay" => { + let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?; + if !(1..=43_200).contains(&minutes) { + return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into())); + } + now.clone() + ChronoDuration::minutes(minutes as i64) + } + "at" => { + let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?; + if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); } + if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); } + at + } + _ => unreachable!(), + }; let finish_kind = request.finish_kind.as_str(); if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") { return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into())); @@ -708,15 +735,15 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl "duration" => { let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?; if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); } - Some(now.clone() + ChronoDuration::minutes(minutes as i64)) + Some(started_at.clone() + ChronoDuration::minutes(minutes as i64)) } "until" => { let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; - if until <= now { return Err(AppError::BadRequest("temporary thermostat end time must be in the future".into())); } - if until > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days away".into())); } + if until <= started_at { return Err(AppError::BadRequest("temporary thermostat end time must be after its start time".into())); } + if until > started_at.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); } Some(until) } - "schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) + "schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, started_at.with_timezone(&chrono::Local)) .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?), _ => None, }; @@ -732,7 +759,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into())); } - Ok(now.clone() + ChronoDuration::minutes(minutes as i64)) + Ok(started_at.clone() + ChronoDuration::minutes(minutes as i64)) }).transpose()? } else { None }; @@ -742,18 +769,32 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref() .and_then(|session| session.restore_zone_enabled) .unwrap_or(zone.enabled); - engine::set_local_thermostat_power(&mut zone, true, now.clone()); - zone.enabled = true; - zone.setpoint = target; - zone.manual_setpoint = Some(target); - zone.effective_setpoint = Some(target); - zone.manual_override_until = None; + let activate_now = start_kind == "now"; + if !activate_now && zone.temporary_quick_thermostat.is_some() { + if zone.local_thermostat_power == Some(true) { + engine::reset_local_thermostat_override(&mut zone); + } else { + zone.temporary_quick_thermostat = None; + } + } + if activate_now { + engine::set_local_thermostat_power(&mut zone, true, now.clone()); + zone.enabled = true; + zone.setpoint = target; + zone.manual_setpoint = Some(target); + zone.effective_setpoint = Some(target); + zone.manual_override_until = None; + } zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat { + start_kind: start_kind.into(), finish_kind: finish_kind.into(), - started_at: now, + started_at, + activated_at: activate_now.then_some(now), restore_zone_enabled: Some(restore_zone_enabled), expires_at, - temperature_target: is_temperature_condition.then_some(target), + // Keep the requested thermostat target even for delayed time-based sessions; + // it is applied only when ownership actually starts. + temperature_target: Some(target), temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()), tolerance_c: tolerance, hold_seconds, @@ -778,10 +819,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl zone.setpoint = value; zone.manual_setpoint = Some(value); zone.effective_setpoint = Some(value); - if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { - session.temperature_target = Some(value); - session.condition_started_at = None; + if zone.local_thermostat_power == Some(true) { + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + session.temperature_target = Some(value); + session.condition_started_at = None; + } } } zone.manual_override_until = if zone.local_thermostat_power == Some(true) { @@ -839,7 +882,9 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl && state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group); engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode); - if (patch.preset.is_some() || patch.clear_override.unwrap_or(false)) && zone.temporary_quick_thermostat.is_some() { + if (patch.preset.is_some() || patch.clear_override.unwrap_or(false)) + && zone.local_thermostat_power == Some(true) + && zone.temporary_quick_thermostat.is_some() { let current_target = zone.effective_setpoint; if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { @@ -1478,7 +1523,13 @@ fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override { return Ok(()); } let schedules = state.db.list_schedules()?; let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); - if zone.manual_preset.is_some() || zone.manual_setpoint.is_some() { zone.manual_override_until = boundary; } + // An active Temporary Quick Thermostat explicitly owns its target until its own finish + // rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and + // accidentally clear that target at the next schedule transition. + let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some(); + if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone { + zone.manual_override_until = boundary; + } if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; } zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); diff --git a/src/engine.rs b/src/engine.rs index ed594a2..709701f 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -665,6 +665,12 @@ fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) } } +fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime) -> Option> { + let session = zone.temporary_quick_thermostat.as_ref()?; + let pending = session.activated_at.is_none() && zone.local_thermostat_power != Some(true) && session.started_at > now; + if pending { Some(session.started_at.clone()) } else { temporary_quick_thermostat_next_deadline(session) } +} + fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result, AppError> { let now = Utc::now(); let mut restored_disabled_zones = Vec::new(); @@ -673,20 +679,62 @@ fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], sche let Some(deadline) = temporary_quick_thermostat_hard_deadline(session) else { continue; }; if deadline > now { continue; } let finish_kind = session.finish_kind.clone(); - let restores_disabled = session.restore_zone_enabled == Some(false); - reset_local_thermostat_override(zone); - refresh_zone_runtime_target(zone, schedules, house_mode); + let was_activated = session.activated_at.is_some() || zone.local_thermostat_power == Some(true); + let restores_disabled = was_activated && session.restore_zone_enabled == Some(false); + if was_activated { + reset_local_thermostat_override(zone); + refresh_zone_runtime_target(zone, schedules, house_mode); + } else { + // A delayed session that expired before it ever acquired ownership must not + // clear unrelated manual/schedule state that was active while it was waiting. + zone.temporary_quick_thermostat = None; + } zone.updated_at = now.clone(); state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": "deadline" + "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, + "reason": if was_activated { "deadline" } else { "expired_before_activation" } })); if restores_disabled { restored_disabled_zones.push(zone.id.clone()); } } Ok(restored_disabled_zones) } +fn activate_due_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]) -> Result<(), AppError> { + let now = Utc::now(); + for zone in zones.iter_mut() { + let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; }; + let already_active = session.activated_at.is_some() || zone.local_thermostat_power == Some(true); + if already_active || session.started_at > now { continue; } + if temporary_quick_thermostat_hard_deadline(session).map(|deadline| deadline <= now).unwrap_or(false) { + continue; + } + + let target = session.temperature_target + .or(zone.manual_setpoint) + .or(zone.effective_setpoint) + .unwrap_or(zone.setpoint); + set_local_thermostat_power(zone, true, now.clone()); + zone.enabled = true; + zone.setpoint = target; + zone.manual_setpoint = Some(target); + zone.effective_setpoint = Some(target); + zone.manual_override_until = None; + if let Some(session) = zone.temporary_quick_thermostat.as_mut() { + session.activated_at = Some(now.clone()); + session.condition_started_at = None; + } + zone.updated_at = now.clone(); + state.db.save_zone(zone)?; + state.broadcast("zone.updated", serde_json::to_value(&*zone)?); + state.log("info", "zone.temporary_quick_thermostat_started", &format!("Temporary Quick Thermostat started for {}", zone.name), json!({ + "zone_id": zone.id, "device_id": zone.device_id, "scheduled_start": true, "target_temperature": target + })); + } + Ok(()) +} + async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) { if zone.enabled || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } let _device_guard = state.lock_device_operation(&zone.device_id).await; @@ -714,6 +762,7 @@ fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickTher /// Update a temperature-based temporary session from the freshly selected room sensor. /// Returns a completion reason when ownership should be handed back immediately. fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime) -> Option { + if zone.local_thermostat_power != Some(true) { return None; } let met = zone.temporary_quick_thermostat.as_ref() .filter(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable")) .map(|session| temporary_temperature_condition_met(zone, session))?; @@ -778,7 +827,7 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result Ok(state.db.list_zones()?.into_iter() .filter_map(|zone| { let local = if local_thermostat_handback_is_active(&zone) { zone.local_thermostat_resume_at.clone() } else { None }; - let temporary = zone.temporary_quick_thermostat.as_ref().and_then(temporary_quick_thermostat_next_deadline); + let temporary = temporary_quick_thermostat_wakeup_at(&zone, now.clone()); match (local, temporary) { (Some(a), Some(b)) => Some(a.min(b)), (Some(a), None) => Some(a), @@ -1072,7 +1121,8 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; - if let Some(mode) = patch.mode.as_deref() { + let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some(); + if let Some(mode) = patch.mode.as_deref().filter(|_| !temporary_owns_zone) { match mode { "house" | "auto" => zone.inherit_house_mode = true, "cool" | "heat" => { @@ -1082,7 +1132,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl _ => {} } } - if let Some(preset) = patch.preset.as_deref() { + if let Some(preset) = patch.preset.as_deref().filter(|_| !temporary_owns_zone) { if preset == "auto" { zone.manual_preset = None; zone.manual_setpoint = None; @@ -1093,6 +1143,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); } } + if temporary_owns_zone && (patch.mode.is_some() || patch.preset.is_some()) { + state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({ + "zone_id": zone.id, "group_id": group.id, "source": source + })); + } zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; @@ -1269,6 +1324,7 @@ async fn control_zones(state: &AppState) -> Result<()> { // is off; no physical state is restored here, only automation ownership. expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)?; let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode)?; + activate_due_temporary_quick_thermostats(state, &mut zone_snapshot)?; // Outdoor temperature is deliberately optional. Prefer the configured Home // Assistant entity, but keep the dashboard/assist useful by falling back to the @@ -1359,11 +1415,11 @@ async fn control_zones(state: &AppState) -> Result<()> { // House "off" means the smart thermostat does not control inherited zones. // A zone explicitly switched to heat/cool remains independent and may still run. - let effective_mode_owned = if zone.inherit_house_mode { - settings.house_mode.clone() - } else { - zone.mode.clone() - }; + // Local Quick Thermostat is an explicit per-zone request. If the inherited house + // climate mode is "off" (no automatic climate control), use the zone's last local + // heat/cool mode while local ownership is ON. The separate whole-house master power + // remains authoritative and is checked before this loop. + let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode); zone.effective_mode = effective_mode_owned.clone(); let ownership_blocked_by_group = zone.local_thermostat_power != Some(true) && groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)); @@ -2055,9 +2111,9 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> } pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) { - let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; - zone.effective_mode = configured_mode.to_string(); - let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode }; + let configured_mode = effective_zone_mode(zone, house_mode); + zone.effective_mode = configured_mode.clone(); + let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() }; let schedule = active_schedule_for_zone(zone, schedules, Local::now()); let (preset, target) = resolve_zone_target(zone, schedule, target_mode); zone.active_preset = preset; @@ -2066,6 +2122,15 @@ pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], hous } } +fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String { + let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; + if zone.local_thermostat_power == Some(true) && configured == "off" { + zone.mode.clone() + } else { + configured.to_string() + } +} + fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime) -> Option<&'a Schedule> { schedules.iter() .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) @@ -2749,8 +2814,10 @@ mod tests { fn temporary_session(now: DateTime) -> TemporaryQuickThermostat { TemporaryQuickThermostat { + start_kind: "now".into(), finish_kind: "temperature_stable".into(), - started_at: now, + started_at: now.clone(), + activated_at: Some(now), restore_zone_enabled: Some(true), expires_at: None, temperature_target: Some(23.0), @@ -2806,6 +2873,58 @@ mod tests { assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none()); } + #[test] + fn delayed_temporary_session_does_not_block_automation_before_start() { + let now = Utc::now(); + let mut zone = test_zone("device"); + let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); + session.start_kind = "delay".into(); + session.activated_at = None; + session.expires_at = Some(now.clone() + chrono::Duration::hours(3)); + zone.temporary_quick_thermostat = Some(session); + + assert!(!device_blocked_by_local_thermostat(&zone.device_id, std::slice::from_ref(&zone))); + assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now.clone()), Some(now + chrono::Duration::hours(1))); + } + + #[test] + fn delayed_temperature_condition_cannot_finish_before_activation() { + let now = Utc::now(); + let mut zone = test_zone("device"); + zone.current_temperature = Some(23.0); + let mut session = temporary_session(now.clone() + chrono::Duration::hours(1)); + session.start_kind = "at".into(); + session.activated_at = None; + zone.temporary_quick_thermostat = Some(session); + + assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now).is_none()); + assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none()); + } + + #[test] + fn temporary_setpoint_keeps_priority_over_active_schedule() { + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(true); + zone.manual_setpoint = Some(23.0); + zone.temporary_quick_thermostat = Some(temporary_session(Utc::now())); + let mut schedule = test_schedule("night", vec![1,2,3,4,5,6,7], "00:00", "00:00"); + schedule.preset = "custom".into(); + schedule.setpoint = 19.0; + + let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool"); + assert_eq!(target, 23.0); + } + + #[test] + fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() { + let mut zone = test_zone("device"); + zone.inherit_house_mode = true; + zone.mode = "cool".into(); + assert_eq!(effective_zone_mode(&zone, "off"), "off"); + zone.local_thermostat_power = Some(true); + assert_eq!(effective_zone_mode(&zone, "off"), "cool"); + } + #[test] fn local_thermostat_off_restarts_backend_handback_deadline() { let mut zone = test_zone("device"); diff --git a/src/models.rs b/src/models.rs index 215401d..1c0bd7d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -38,6 +38,7 @@ fn default_night_end() -> String { "06:00".into() } fn default_night_max_fan_speed() -> u8 { 1 } fn default_group_power_enabled() -> bool { true } fn default_temporary_tolerance() -> f64 { 0.3 } +fn default_temporary_start_kind() -> String { "now".into() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { @@ -271,9 +272,18 @@ impl From<&Device> for ManualDeviceBaseline { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemporaryQuickThermostat { + /// now | delay | at. `started_at` is the effective start instant and may be in the future. + #[serde(default = "default_temporary_start_kind")] + pub start_kind: String, /// duration | until | temperature_reached | temperature_stable | schedule_boundary pub finish_kind: String, + /// Effective start instant. Future values mean the temporary thermostat is scheduled + /// but does not yet own the zone or block normal schedules/automations. pub started_at: DateTime, + /// Set when the controller actually activates ownership. Kept separate from started_at + /// so a delayed session can survive restarts without being mistaken for an active one. + #[serde(default)] + pub activated_at: Option>, /// Zone automation enabled-state from before the temporary session. A temporary /// Quick Thermostat may run even when normal automation was disabled, then restore it. #[serde(default)] @@ -302,6 +312,12 @@ pub struct TemporaryQuickThermostat { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemporaryQuickThermostatRequest { + #[serde(default = "default_temporary_start_kind")] + pub start_kind: String, + #[serde(default)] + pub start_delay_minutes: Option, + #[serde(default)] + pub start_at: Option>, pub finish_kind: String, #[serde(default)] pub duration_minutes: Option, diff --git a/web/app.js b/web/app.js index 75af655..886a3cc 100644 --- a/web/app.js +++ b/web/app.js @@ -86,12 +86,25 @@ function temporarySessionStatus(zone) { }; const safetyRemaining = secondsUntil(session.safety_expires_at); const hardRemaining = secondsUntil(session.expires_at); + const startRemaining = secondsUntil(session.started_at); + const pending = !session.activated_at && zone.local_thermostat_power !== true; const target = Number(session.temperature_target ?? zone.effective_setpoint ?? zone.setpoint); const targetText = Number.isFinite(target) ? target.toFixed(1) : '--'; const tolerance = Number(session.tolerance_c ?? 0.3).toFixed(1); const operatorKey = ({within:'zones.temporaryWithinShort',at_or_below:'zones.temporaryAtOrBelowShort',at_or_above:'zones.temporaryAtOrAboveShort'})[session.temperature_operator || 'within'] || 'zones.temporaryWithinShort'; const condition = tr(operatorKey, {target:targetText, tolerance}); + if (pending) { + return { + countdown:startRemaining != null && startRemaining > 0 ? formatExtendedCountdown(startRemaining) : '00:00', + detail:startRemaining != null && startRemaining > 0 + ? tr('zones.temporaryScheduledStatus', {time:dateTime(session.started_at)}) + : tr('zones.temporaryStartingStatus'), + kind:session.finish_kind, + pending:true, + }; + } + if (session.finish_kind === 'temperature_stable') { const started = session.condition_started_at ? new Date(session.condition_started_at).getTime() : NaN; const holdSeconds = Number(session.hold_seconds || 0); @@ -123,6 +136,8 @@ function updateTemporaryThermostatCountdowns() { if (status) { $('#temporaryThermostatActiveCountdown').textContent = status.countdown; $('#temporaryThermostatActiveDetail').textContent = status.detail; + const label = $('#temporaryThermostatActiveLabel'); + if (label) label.textContent = tr(status.pending ? 'zones.temporaryScheduled' : 'zones.temporaryActive'); } } } @@ -841,7 +856,7 @@ function zoneCard(zone, detailed = true) { const localRequestedOn = localThermostatPower === true ? true : (localThermostatPower === false ? false : !!device?.power); const localThermostatPowerControl = ``; const temporaryThermostatControl = temporarySession - ? `` + ? `` : ``; if (detailed) { @@ -1389,16 +1404,23 @@ const dateTimeLocalValue = value => { function updateTemporaryThermostatFields() { const form = $('#temporaryThermostatForm'); if (!form) return; + const startKind = form.start_kind.value; const kind = form.finish_kind.value; + const startDelay = $('#temporaryStartDelayFields'); + const startAt = $('#temporaryStartAtFields'); const duration = $('#temporaryDurationFields'); const until = $('#temporaryUntilFields'); const temperature = $('#temporaryTemperatureFields'); const schedule = $('#temporaryScheduleFields'); + startDelay.hidden = startKind !== 'delay'; + startAt.hidden = startKind !== 'at'; duration.hidden = kind !== 'duration'; until.hidden = kind !== 'until'; temperature.hidden = !['temperature_reached','temperature_stable'].includes(kind); schedule.hidden = kind !== 'schedule_boundary'; $('#temporaryHoldField').hidden = kind !== 'temperature_stable'; + form.start_delay_minutes.required = startKind === 'delay'; + form.start_at.required = startKind === 'at'; form.duration_minutes.required = kind === 'duration'; form.until.required = kind === 'until'; form.hold_minutes.required = kind === 'temperature_stable'; @@ -1410,9 +1432,17 @@ function populateTemporaryThermostat(id) { form.reset(); form.elements.zone_id.value = zone.id; form.target_temperature.value = Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint ?? 23).toFixed(1); + form.start_kind.value = 'now'; + form.start_delay_minutes.value = '30'; + form.start_at.value = dateTimeLocalValue(new Date(Date.now() + 60 * 60 * 1000)); form.until.value = dateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000)); const session = zone.temporary_quick_thermostat; if (session) { + form.start_kind.value = session.start_kind || (new Date(session.started_at).getTime() > Date.now() ? 'at' : 'now'); + if (form.start_kind.value === 'delay' && session.started_at) { + form.start_delay_minutes.value = Math.max(1, Math.ceil((new Date(session.started_at).getTime() - Date.now()) / 60000)); + } + if (form.start_kind.value === 'at' && session.started_at) form.start_at.value = dateTimeLocalValue(session.started_at); form.finish_kind.value = session.finish_kind || 'duration'; if (session.temperature_target != null) form.target_temperature.value = Number(session.temperature_target).toFixed(1); if (session.temperature_operator) form.temperature_operator.value = session.temperature_operator; @@ -1420,10 +1450,18 @@ function populateTemporaryThermostat(id) { if (session.hold_seconds) form.hold_minutes.value = Math.max(1, Math.round(Number(session.hold_seconds) / 60)); if (session.expires_at) { form.until.value = dateTimeLocalValue(session.expires_at); - if (session.finish_kind === 'duration') form.duration_minutes.value = Math.max(1, Math.ceil((new Date(session.expires_at).getTime() - Date.now()) / 60000)); + if (session.finish_kind === 'duration') { + const reference = (!session.activated_at && zone.local_thermostat_power !== true && session.started_at) + ? new Date(session.started_at).getTime() + : Date.now(); + form.duration_minutes.value = Math.max(1, Math.ceil((new Date(session.expires_at).getTime() - reference) / 60000)); + } } if (session.safety_expires_at) { - form.max_duration_minutes.value = Math.max(1, Math.ceil((new Date(session.safety_expires_at).getTime() - Date.now()) / 60000)); + const reference = (!session.activated_at && zone.local_thermostat_power !== true && session.started_at) + ? new Date(session.started_at).getTime() + : Date.now(); + form.max_duration_minutes.value = Math.max(1, Math.ceil((new Date(session.safety_expires_at).getTime() - reference) / 60000)); } else if (['temperature_reached','temperature_stable'].includes(session.finish_kind)) { form.max_duration_minutes.value = ''; } @@ -2110,6 +2148,11 @@ $('#zoneForm').addEventListener('submit', async event => { }); $('#temporaryFinishKind')?.addEventListener('change', updateTemporaryThermostatFields); +$('#temporaryStartKind')?.addEventListener('change', updateTemporaryThermostatFields); +$$('[data-temporary-start-delay]').forEach(button => button.addEventListener('click', () => { + const form = $('#temporaryThermostatForm'); + form.start_delay_minutes.value = button.dataset.temporaryStartDelay; +})); $$('[data-temporary-duration]').forEach(button => button.addEventListener('click', () => { const form = $('#temporaryThermostatForm'); form.duration_minutes.value = button.dataset.temporaryDuration; @@ -2129,11 +2172,21 @@ $('#temporaryThermostatStop')?.addEventListener('click', async event => { $('#temporaryThermostatForm')?.addEventListener('submit', async event => { event.preventDefault(); const form = event.currentTarget, raw = Object.fromEntries(new FormData(form)); - const request = {finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)}; + const request = {start_kind:raw.start_kind, finish_kind:raw.finish_kind, target_temperature:parseDecimal(raw.target_temperature)}; + if (raw.start_kind === 'delay') request.start_delay_minutes = Number(raw.start_delay_minutes); + if (raw.start_kind === 'at') { + const startAt = new Date(raw.start_at); + if (!Number.isFinite(startAt.getTime())) return toast(tr('zones.temporaryInvalidStartAt'), true); + request.start_at = startAt.toISOString(); + } if (raw.finish_kind === 'duration') request.duration_minutes = Number(raw.duration_minutes); if (raw.finish_kind === 'until') { const until = new Date(raw.until); if (!Number.isFinite(until.getTime())) return toast(tr('zones.temporaryInvalidUntil'), true); + const effectiveStart = raw.start_kind === 'delay' + ? new Date(Date.now() + Number(raw.start_delay_minutes) * 60000) + : raw.start_kind === 'at' ? new Date(raw.start_at) : new Date(); + if (Number.isFinite(effectiveStart.getTime()) && until <= effectiveStart) return toast(tr('zones.temporaryEndAfterStart'), true); request.until = until.toISOString(); } if (['temperature_reached','temperature_stable'].includes(raw.finish_kind)) { @@ -2147,7 +2200,7 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => { $('#temporaryThermostatSubmit').disabled = true; const zone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; - form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStarted')); + form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(raw.start_kind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); } catch (error) { toast(error.message, true); } finally { $('#temporaryThermostatSubmit').disabled = false; } }); diff --git a/web/index.html b/web/index.html index 5d545a1..31876f2 100644 --- a/web/index.html +++ b/web/index.html @@ -407,10 +407,25 @@
Szybki termostat

Włącz czasowo

+ + + + + +