This commit is contained in:
Mateusz Gruszczyński
2026-08-28 10:04:35 +02:00
parent d159795267
commit eb325cc0c8
15 changed files with 392 additions and 653 deletions
+32 -588
View File
@@ -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.<zone_name>_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.<zone_name>_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.
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.8.3"
version = "0.8.4"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+14 -14
View File
@@ -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
+11 -2
View File
@@ -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.
+11 -3
View File
@@ -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
@@ -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",
+12
View File
@@ -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."
}
}
+12
View File
@@ -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."
}
}
+71 -20
View File
@@ -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();
+135 -16
View File
@@ -665,6 +665,12 @@ fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat)
}
}
fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
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<Vec<String>, 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<Utc>) -> Option<String> {
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<Option<Duration>
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<Local>) -> 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<Utc>) -> 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");
+16
View File
@@ -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<Utc>,
/// 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<DateTime<Utc>>,
/// 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<u64>,
#[serde(default)]
pub start_at: Option<DateTime<Utc>>,
pub finish_kind: String,
#[serde(default)]
pub duration_minutes: Option<u64>,
+58 -5
View File
@@ -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 = `<button type="button" class="zone-unit-power-toggle ${localRequestedOn ? 'active' : ''}" title="${esc(tr('zones.unitPowerLocalHint'))}" data-action="zone-device-power" data-id="${esc(zone.id)}" data-value="${localRequestedOn ? 'false' : 'true'}" aria-pressed="${localRequestedOn ? 'true' : 'false'}"${localPowerDisabled}><span aria-hidden="true">⏻</span><b>${esc(tr(localRequestedOn ? 'common.on' : 'common.off'))}</b></button>`;
const temporaryThermostatControl = temporarySession
? `<button type="button" class="temporary-thermostat-toggle active" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><span aria-hidden="true">⏱</span><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
? `<button type="button" class="temporary-thermostat-toggle active ${temporaryStatus?.pending ? 'scheduled' : ''}" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(temporaryStatus?.detail || tr('zones.temporaryActive'))}"><span aria-hidden="true">⏱</span><b data-temporary-countdown-zone="${esc(zone.id)}">${esc(temporaryStatus?.countdown || '—')}</b></button>`
: `<button type="button" class="temporary-thermostat-toggle" data-action="zone-open-temporary" data-id="${esc(zone.id)}" title="${esc(tr('zones.temporaryStartHint'))}"${localPowerDisabled}><span aria-hidden="true">⏱</span><b>${esc(tr('zones.temporaryShort'))}</b></button>`;
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; }
});
+16 -1
View File
@@ -407,10 +407,25 @@
<input type="hidden" name="zone_id">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="zones.quickThermostat">Szybki termostat</span><h2 data-i18n="zones.temporaryTitle">Włącz czasowo</h2></div><button type="button" data-close>×</button></div>
<div id="temporaryThermostatActive" class="temporary-active-summary" hidden>
<div><small data-i18n="zones.temporaryActive">Aktywny tryb czasowy</small><strong id="temporaryThermostatActiveCountdown"></strong><span id="temporaryThermostatActiveDetail"></span></div>
<div><small id="temporaryThermostatActiveLabel" data-i18n="zones.temporaryActive">Aktywny tryb czasowy</small><strong id="temporaryThermostatActiveCountdown"></strong><span id="temporaryThermostatActiveDetail"></span></div>
<button type="button" class="danger" id="temporaryThermostatStop" data-i18n="zones.temporaryStop">Zakończ teraz</button>
</div>
<label><span data-i18n="zones.temporaryTarget">Temperatura szybkiego termostatu</span><input type="number" name="target_temperature" min="8" max="30" step="0.5" required></label>
<label><span data-i18n="zones.temporaryStartRule">Kiedy uruchomić</span><select name="start_kind" id="temporaryStartKind">
<option value="now" data-i18n="zones.temporaryStartNow">Od razu</option>
<option value="delay" data-i18n="zones.temporaryStartDelay">Z opóźnieniem</option>
<option value="at" data-i18n="zones.temporaryStartAt">O konkretnej dacie i godzinie</option>
</select></label>
<div id="temporaryStartDelayFields" class="temporary-rule-fields" hidden>
<label><span data-i18n="zones.temporaryStartDelayMinutes">Uruchom za (min)</span><input type="number" name="start_delay_minutes" min="1" max="43200" value="30"></label>
<div class="temporary-presets"><button type="button" data-temporary-start-delay="15">15 min</button><button type="button" data-temporary-start-delay="30">30 min</button><button type="button" data-temporary-start-delay="60">1 h</button><button type="button" data-temporary-start-delay="120">2 h</button></div>
</div>
<div id="temporaryStartAtFields" class="temporary-rule-fields" hidden>
<label><span data-i18n="zones.temporaryStartAtDate">Uruchom o</span><input type="datetime-local" name="start_at"></label>
</div>
<label><span data-i18n="zones.temporaryFinishRule">Kiedy wrócić do automatyki</span><select name="finish_kind" id="temporaryFinishKind">
<option value="duration" data-i18n="zones.temporaryDuration">Po określonym czasie</option>
<option value="until" data-i18n="zones.temporaryUntil">O konkretnej dacie i godzinie</option>
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'gree-controller-v083-temporary-quick-thermostat';
const CACHE = 'gree-controller-v084-scheduled-temporary-quick-thermostat';
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];