v0.4.4
This commit is contained in:
+42
-31
@@ -1,49 +1,60 @@
|
||||
# GREE Controller v0.4.3 - build and validation report
|
||||
# GREE Controller v0.4.4 - build and validation report
|
||||
|
||||
## Scope
|
||||
|
||||
Version 0.4.3 restores the classic rounded GREE Controller UI while keeping the neutral light/dark palette and green accents introduced in the previous release. It also removes three Rust dead-code warnings reported by the LXC release build.
|
||||
Version 0.4.4 fixes empty/under-populated history views and expands climate telemetry across GREE devices, zones and Home Assistant sensors. It also adds URL-addressable UI pages, History sub-pages, a saved/shareable custom chart composer, redesigned toasts and optimistic Quick Thermostat controls that no longer clear the active preset.
|
||||
|
||||
## UI changes
|
||||
## History and chart changes
|
||||
|
||||
- Restored the classic large rounded cards and panels.
|
||||
- Restored circular thermostat +/- and power controls.
|
||||
- Restored compact desktop navigation and five-item mobile bottom navigation.
|
||||
- Kept the v0.4.2 climate-history functionality and multi-series charts.
|
||||
- Kept neutral dark/light surfaces.
|
||||
- Dark accent: `#3ecf8e`.
|
||||
- Light accent: `#24b47e`.
|
||||
- UI is plain committed CSS in `web/styles.css`; no frontend package manager or CSS build step is used.
|
||||
- Theme-aware scrollbars remain enabled and compact horizontal control scrollbars remain hidden.
|
||||
- `readings` remains the independent history source for every GREE device, whether or not the device belongs to a zone.
|
||||
- Added bucketed device-history queries for indoor temperature, available GREE `OutEnvTem`, target and power.
|
||||
- Added `ha_readings` for configured HA room/outdoor sensors.
|
||||
- Existing `zone_readings` remains the rich thermostat timeline with GREE temperature, HA room temperature, control temperature, target, actual device setpoint, outdoor temperature, power, mode, fan, demand, source and preset.
|
||||
- `/api/history?scope=overview` returns device, zone and HA streams together plus row counts.
|
||||
- If a zone has no `zone_readings` yet, the API reconstructs a compatibility timeline from its existing device `readings` instead of returning an empty chart.
|
||||
- Older rich zone samples can be exposed as compatibility HA history when the dedicated HA table has not accumulated samples yet.
|
||||
- GREE polling now probes `OutEnvTem` separately after a core-property fallback so models that expose outdoor temperature can contribute that series without making the main poll fail.
|
||||
- History UI is split into Overview, Zones, GREE devices, HA sensors and Custom chart sub-pages.
|
||||
- Overview includes common charts for all GREE indoor temperatures, available outdoor temperatures and all zone control temperatures.
|
||||
- The custom chart composer can mix GREE device, zone and HA sensor series, save chart definitions in browser storage and serialize them into a shareable URL.
|
||||
|
||||
## Rust warning cleanup
|
||||
## Browser navigation and feedback
|
||||
|
||||
Removed code that was superseded by bucketed zone history:
|
||||
- Main application views use stable paths such as `/dashboard`, `/devices`, `/zones`, `/schedules`, `/automations`, `/settings` and `/events`.
|
||||
- History uses `/history/overview`, `/history/zones`, `/history/devices`, `/history/sensors` and `/history/custom`.
|
||||
- Browser Back/Forward and direct page refresh are handled by History API routing plus the server-side index fallback.
|
||||
- Toasts are now stacked, theme-aware success/error cards with close controls and progress indicators.
|
||||
|
||||
- `Db::list_zone_readings`
|
||||
- `queries::LIST_ZONE_READINGS_BY_ZONE`
|
||||
- `queries::LIST_ZONE_READINGS_ALL`
|
||||
## Quick Thermostat fix
|
||||
|
||||
The active history path is `Db::list_zone_history` with the bucketed SQL queries.
|
||||
- `+/-` updates the displayed target immediately and batches rapid taps with a short debounce.
|
||||
- Stale HTTP responses cannot overwrite a newer tap.
|
||||
- Added `manual_setpoint` separately from `manual_preset`.
|
||||
- A temperature nudge no longer changes Auto/Comfort/Sleep/Away to `custom` or clears the active preset.
|
||||
- Manual temperature correction still expires at the next schedule boundary.
|
||||
|
||||
## SQLite / compatibility
|
||||
|
||||
- Added `ha_readings` and indexes through idempotent `CREATE TABLE IF NOT EXISTS` schema initialization.
|
||||
- All SQL remains centralized in `src/queries.rs`.
|
||||
- Existing `readings`, devices, zones, schedules, automations and settings are preserved.
|
||||
- No database reset is required.
|
||||
|
||||
## Validation performed in packaging environment
|
||||
|
||||
- HTML structure sanity checks.
|
||||
- JavaScript syntax check with Node's parser only; Node is not part of the application/runtime.
|
||||
- JSON validation for language files and HA manifest.
|
||||
- Shell syntax checks for `scripts/*.sh`.
|
||||
- Python syntax check for migration helper.
|
||||
- Verified no `list_zone_readings` or `LIST_ZONE_READINGS_*` references remain.
|
||||
- Verified no frontend build/package files are required.
|
||||
- ZIP integrity test and SHA-256 manifest generation.
|
||||
- JavaScript syntax: `node --check web/app.js`.
|
||||
- JSON validation for EN/PL language packs, PWA manifest and HA manifest.
|
||||
- Shell syntax validation for every `scripts/*.sh` file.
|
||||
- Python syntax validation for helper scripts.
|
||||
- SQLite execution tests for the schema, device bucket queries, zone bucket queries, HA bucket queries and history counts.
|
||||
- EN/PL translation-key parity check.
|
||||
- Verified project SQL statements remain centralized in `src/queries.rs`.
|
||||
- Verified no Node/npm/Tailwind runtime or build dependency exists.
|
||||
- ZIP integrity and SHA-256 manifest verification.
|
||||
|
||||
A Rust toolchain is not available in the packaging environment, so the authoritative Rust tests and warning check run on the LXC through `scripts/update.sh` before installation.
|
||||
|
||||
Expected LXC validation:
|
||||
A Rust toolchain is not installed in the packaging environment. The authoritative Rust compile/tests are therefore intentionally performed on the target LXC by `scripts/update.sh` before it replaces the running binary:
|
||||
|
||||
```bash
|
||||
cargo test --all-targets
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
The three warnings supplied from v0.4.2 have been removed at source rather than suppressed.
|
||||
|
||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+19
-20
@@ -1,15 +1,15 @@
|
||||
da945a594f47b2b0c326cc0275b10a23cb509f545a1841ba9c57f00086e553ff ./.env.example
|
||||
17780b6416a709efae772c37fb1151a35e51e6e8e6f428566d2f3eb308a4c58d ./.gitignore
|
||||
d8981625e50f5df8633f17c82fd24f8f0ee1f7fdc85feda77d82aa910fcaa214 ./BUILD_REPORT.md
|
||||
f0a46de96da26d647d40de85670473502457e8527d219e4a4b94f12faec653bd ./Cargo.toml
|
||||
a0bb10c1f004d885e0f5c5aad7598d88183f6875c29f45b9f86b3b4a78b6b4f3 ./BUILD_REPORT.md
|
||||
d2593b3f75f1f32de05ab4553c3816d1492cabb4c0aaa157803713a594f75109 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
c24b1043c8e362bb1478fb7512d324c30b20b17658a34aaabc6ea99c34e6c69b ./README.md
|
||||
2694b81e62f5c968e4f4bd17c778c96333febd6229c0d70e77cd6d4a64c628f1 ./README.md
|
||||
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
||||
0fb1606ecf67d7c73c53c4a9011d7ece4f2aa3b678eefeb446c4af5c28f91295 ./docs/API.md
|
||||
177916e2d5476f643e2f8aae31d2ac8a67eee3b0f010bf7898c0d6d232685f74 ./docs/API.md
|
||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||
8fa2f24618026d9d5c39343c0a6a402c6329c384d76df63297ba026dcea11ee0 ./docs/PROJECT_SPEC.md
|
||||
6309591814958f059f18ad7677f925a6f6e4a0eab4b780519fc7ed2067e98d56 ./docs/PROJECT_SPEC.md
|
||||
33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
e0cf725c9f84be51cdbd83a5ab12b2b8288f90b36623cb4671cefcbf04378504 ./home-assistant/custom_components/gree_controller/api.py
|
||||
@@ -18,15 +18,14 @@ fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assista
|
||||
e1821b74859bc40773a6ee39e6ccc9650980b62af50d6426b46cdb3e3a90d200 ./home-assistant/custom_components/gree_controller/const.py
|
||||
2d27d7cb67c53e819b99a0302cd0bd339c27e3e2dfc85e07d30c31f505f740b5 ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||
e361220da4193b6c02f06718aa6240b6af54f1c6330916dd66f0ea5ea8f26542 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
7eacd4203d75e946444a09f43c76f756dda7698f92a42251f416c83fd0038d15 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
c871974574a31e6c90e3572c6cae73fd014a9b2dffd486936bcf1a93727eb95d ./lang/en.json
|
||||
27e7edd19b2010e6ab71ccb397cf94e99e390d9caca3eac83000d558d871b990 ./lang/pl.json
|
||||
74fd82612a159dbbb8f0eca023f90407240c65e766b9642974ea4dc5a3d0ce47 ./lang/en.json
|
||||
6a8edb6fb158865dfe04b502f848f1672444e0e3af573bcc351484a16538ac15 ./lang/pl.json
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
896caae04468f743d85d011638a0683253d8271c0db8365aea9bcdc646795cf1 ./scripts/__pycache__/generate_ha_migration.cpython-313.pyc
|
||||
3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
|
||||
@@ -37,23 +36,23 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
9620b0d32a24ec8111b46f34f4502565dfc601896ef92ba9be74fc25a4bd020f ./src/api.rs
|
||||
b7a82ec69207464149e8777691da639ae12cb621cf0e41106d326a791d3e9b12 ./src/api.rs
|
||||
5138f9762540da497360667bd93e5e7274515a37baf3ca3c19c38b23a57c9e2b ./src/config.rs
|
||||
53f4d02b6e7259525c45766a06002f2094ce51bc0b6ec1a04b2fe039cd8714f7 ./src/db.rs
|
||||
3f5ffc1ff1fabab5952506d26349f37c23ea270e1351f0e8579a920c1bbc0f84 ./src/engine.rs
|
||||
2e3effc5d6a716d0bbfcd64ee7bc9a44ba6751778b963ad63e02bbd56a96e24b ./src/db.rs
|
||||
ff93dba5c02d7e3bbd07b43d4b591b89a4d58305f05abb55544231636b121cd9 ./src/engine.rs
|
||||
ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
||||
584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs
|
||||
8f4fa47395451b2d9ee6270aae63dd8e7df9fa74182a3429b10aadac1029b090 ./src/main.rs
|
||||
150893b11ef45ddecc17302ece8dbcb77dd3d1fe4dc67bf8f2b75ead95c13fea ./src/models.rs
|
||||
6b3b70c23e0ffb6a22070be8050a10be34e105a44bc7a7b417f5ce73186a7956 ./src/models.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
b66ae7c0186b6cc3939d922b0a83f763fd670662c170c97cebca8235d99f663c ./src/protocol/gree.rs
|
||||
aeb498eb7533d44930affa443927706ed4febef3050f2ca8b639d7742f8f94b9 ./src/protocol/gree.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
6ca5aea6efd4e8c1c9ec0ed81289c0f964a3f82117859439d62750c250a31822 ./src/queries.rs
|
||||
f8e5daeff84a773455ce7ef96bd217f420b46b938d3f695459ced89b8f7c8914 ./src/queries.rs
|
||||
3bd56018114a7199082e9e9d973107b73cb8259693a80743f4022364cda62095 ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
7b2f7054ab62d33fe9f43c9cab5056b3de2e4c3196e168f8669a640f5bf2d77b ./web/app.js
|
||||
2ab5573f1fa4a773fcf446c48861c51bf519ee482934a1eb7d2f5e039f0d4e3a ./web/app.js
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
c5d82744adbd17e00b21f48abf279f3362345ac12f7d0fb769f21cb97b7c0b41 ./web/index.html
|
||||
6db5f01ef1284c6de175c411fb7014a7b312436bc5964ee8086ed94650047b8c ./web/manifest.webmanifest
|
||||
973c283e1c69c2c050ca863c6f190429280d06d6fc6b6c93648c4678076aeeb1 ./web/styles.css
|
||||
187af1d3b5c6e26c06a7b3b87c31fa0abe296343f56d4fe167fbd8377b0ee02e ./web/sw.js
|
||||
f0e231f6c8a8223e0e00620c9cc4765c344b24e61e3aa3d26bf207692f2912fc ./web/index.html
|
||||
c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest
|
||||
44d61dff54a348ed3e8a9606f57c52a1ddcc764f128531c14b015b4da4f024ab ./web/styles.css
|
||||
98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js
|
||||
|
||||
@@ -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.4.3**.
|
||||
Current version: **0.4.4**.
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -39,7 +39,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.4.3.zip
|
||||
unzip gree-controller-v0.4.4.zip
|
||||
cd gree-controller
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/dev.sh
|
||||
@@ -85,7 +85,7 @@ Appearance selector:
|
||||
- Light,
|
||||
- Dark.
|
||||
|
||||
The selected appearance is stored in the `gree_controller_theme` cookie. Version 0.4.3 restores the classic GREE Controller interface: large rounded cards, circular thermostat controls, compact desktop navigation and mobile bottom navigation. It keeps the newer neutral dark/light palette and green accent (`#3ecf8e` in dark mode and `#24b47e` in light mode).
|
||||
The selected appearance is stored in the `gree_controller_theme` cookie. Version 0.4.4 keeps the classic GREE Controller interface: large rounded cards, circular thermostat controls, compact desktop navigation and mobile bottom navigation. It keeps the newer neutral dark/light palette and green accent (`#3ecf8e` in dark mode and `#24b47e` in light mode).
|
||||
|
||||
### Static offline CSS
|
||||
|
||||
@@ -160,7 +160,19 @@ Settings changed from the web panel are stored in SQLite. `GREE_CONTROLLER_APP_T
|
||||
|
||||
## Climate history
|
||||
|
||||
History is stored per zone so one timeline can include the complete control context: GREE indoor temperature, the optional Home Assistant room sensor, calculated control temperature, comfort/profile target, actual device setpoint, outdoor HA temperature, demand, power, mode and fan speed. The History screen supports a detailed single-zone view and an **All zones** comparison chart for room/control temperatures and targets. Rich zone samples are throttled to at least 15 seconds (or the configured device poll interval, whichever is longer) to keep SQLite compact.
|
||||
History is collected independently for physical GREE devices, control zones, and configured Home Assistant temperature sensors. Device samples continue to use the long-standing `readings` table, so upgrading does not require waiting for a new zone-history table to fill. When rich `zone_readings` are not available yet, the API automatically reconstructs a compatible zone timeline from existing device readings.
|
||||
|
||||
The History area has linkable sub-pages:
|
||||
|
||||
- `/history/overview` — all GREE indoor temperatures, available GREE/HA outdoor temperatures, and all zone control temperatures,
|
||||
- `/history/zones` — all zones or one detailed zone with GREE, HA room, control, target and device-setpoint series,
|
||||
- `/history/devices` — indoor temperature, available `OutEnvTem`, and target for every GREE unit,
|
||||
- `/history/sensors` — configured Home Assistant room/outdoor temperature sensors,
|
||||
- `/history/custom` — compose arbitrary series, save chart definitions in the browser and copy a URL that recreates the chart.
|
||||
|
||||
A zone timeline includes GREE indoor temperature, optional HA room temperature, calculated control temperature, active profile target, actual setpoint sent to the AC, available outdoor temperature, demand, power, mode and fan speed. HA sensor samples and zone samples are throttled to at least 15 seconds (or the configured device poll interval, whichever is longer). Longer ranges are bucketed in SQLite to keep the UI responsive.
|
||||
|
||||
All main UI views also use browser URLs (`/dashboard`, `/devices`, `/zones`, `/schedules`, `/automations`, `/settings`, `/events`) so refresh, browser Back/Forward, bookmarks and direct links work normally.
|
||||
|
||||
## Per-zone room temperature sensors
|
||||
|
||||
|
||||
+24
-6
@@ -176,12 +176,30 @@ curl "$BASE/lang/en.json"
|
||||
|
||||
### `GET /api/history`
|
||||
|
||||
Returns rich per-zone climate history. Query parameters:
|
||||
The history API exposes three independent data families and a combined overview. Common parameters are `hours=6|24|168|720` and `limit` up to 20,000 rows. Downsampling is performed in SQLite: 30-second buckets up to 6 hours, 2-minute buckets up to 24 hours, 10-minute buckets up to 7 days, and 30-minute buckets for longer ranges.
|
||||
|
||||
- `zone_id=<zone id>` for one zone, or `zone_id=all` for all zones,
|
||||
- `hours=6|24|168|720`,
|
||||
- `limit` up to 20,000 rows.
|
||||
```text
|
||||
GET /api/history?scope=overview&hours=24
|
||||
GET /api/history?scope=devices&device_id=DEVICE_ID&hours=24
|
||||
GET /api/history?scope=zones&zone_id=ZONE_ID&hours=24
|
||||
GET /api/history?scope=sensors&entity_id=sensor.room_temperature&hours=24
|
||||
```
|
||||
|
||||
Each row contains `gree_temperature`, `external_temperature` (HA room sensor), `control_temperature`, `target_temperature`, `device_setpoint`, `outdoor_temperature`, `power`, `mode`, `fan_speed`, `demand`, `control_source`, and `active_preset`.
|
||||
`scope=overview` returns:
|
||||
|
||||
The endpoint automatically downsamples in SQLite to keep charts responsive: 30-second buckets up to 6 hours, 2-minute buckets up to 24 hours, 10-minute buckets up to 7 days, and 30-minute buckets for longer ranges.
|
||||
```json
|
||||
{
|
||||
"scope": "overview",
|
||||
"bucket_seconds": 120,
|
||||
"zones": [],
|
||||
"devices": [],
|
||||
"sensors": [],
|
||||
"counts": {"devices": 0, "zones": 0, "ha": 0}
|
||||
}
|
||||
```
|
||||
|
||||
Device rows come from the existing `readings` table and contain `indoor_temperature`, optional GREE `outdoor_temperature`, `target_temperature`, `power`, and `source`. This makes old device history immediately available after an upgrade.
|
||||
|
||||
Zone rows contain `gree_temperature`, `external_temperature` (HA room sensor), `control_temperature`, `target_temperature`, `device_setpoint`, `outdoor_temperature`, `power`, `mode`, `fan_speed`, `demand`, `control_source`, and `active_preset`. If a zone has no rich samples yet, the API falls back to that zone's existing GREE device readings instead of returning an empty timeline.
|
||||
|
||||
Sensor rows contain `entity_id`, optional `zone_id`, `kind` (`room` or `outdoor`), `timestamp`, and `temperature`. New HA samples start being collected after the upgrade; where older rich zone samples contain the same HA values, the API can expose them as compatibility history.
|
||||
|
||||
@@ -175,7 +175,7 @@ The zone controller supports `device`, `combined`, and `home_assistant` temperat
|
||||
- Every SQLite statement and schema definition is centralized in `src/queries.rs`; database/domain code must not embed SQL strings elsewhere.
|
||||
|
||||
|
||||
## Web UI design system (v0.4.3)
|
||||
## Web UI design system (v0.4.4)
|
||||
|
||||
The embedded UI uses the classic GREE Controller visual language: large rounded surfaces, circular thermostat controls, comfortable spacing, compact desktop navigation and mobile bottom navigation. Light, Dark and System themes use neutral surfaces with a restrained green accent.
|
||||
|
||||
@@ -184,3 +184,11 @@ The UI ships as a normal static `web/styles.css` file embedded into the Rust bin
|
||||
### Rich zone history
|
||||
|
||||
Zone history stores GREE temperature, optional Home Assistant room temperature, calculated control temperature, profile target, actual device setpoint, outdoor temperature assist, power, mode, fan speed, demand, active preset and control source. The UI can compare all zones on common temperature/target charts or inspect one zone in detail.
|
||||
|
||||
### History information architecture (v0.4.4)
|
||||
|
||||
History is not a single long page. It is split into URL-addressable views: Overview, Zones, GREE devices, HA sensors and Custom chart. Device history exists independently from zones, HA sensors have their own history stream, and zone history adds the thermostat/control context. A compatibility fallback maps existing GREE device readings into a zone timeline when the richer zone table is still empty.
|
||||
|
||||
The custom chart composer can mix GREE indoor/outdoor/target values, zone GREE/HA/control/target/device-setpoint/outdoor values, and HA temperature entities. Saved definitions persist in browser storage and a chart definition can be serialized into the `/history/custom?chart=...` URL for direct sharing/bookmarking.
|
||||
|
||||
Quick thermostat temperature nudges are optimistic in the browser and stored separately from the selected Comfort/Sleep/Away/Auto preset. Changing `+/-` therefore does not silently leave Auto mode.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.4",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
+48
-1
@@ -305,6 +305,53 @@
|
||||
"history.targetShort": "Target",
|
||||
"history.sensor": "Indoor unit",
|
||||
"history.homeAssistant": "Home Assistant",
|
||||
"history.assist": "Outdoor assist"
|
||||
"history.assist": "Outdoor assist",
|
||||
"history.tabOverview": "Overview",
|
||||
"history.tabZones": "Zones",
|
||||
"history.tabDevices": "GREE devices",
|
||||
"history.tabSensors": "HA sensors",
|
||||
"history.tabCustom": "Custom chart",
|
||||
"history.overviewHint": "All rooms, units and available outdoor measurements in one place.",
|
||||
"history.customHint": "Compose your own chart from GREE, zone and Home Assistant data.",
|
||||
"history.allDevices": "All GREE devices",
|
||||
"history.allSensors": "All HA sensors",
|
||||
"history.haSensor": "HA sensor",
|
||||
"history.deviceSamples": "GREE samples",
|
||||
"history.zoneSamples": "Zone samples",
|
||||
"history.haSamples": "HA samples",
|
||||
"history.greeHistory": "Device polling history",
|
||||
"history.zoneHistory": "Thermostat history",
|
||||
"history.haHistory": "External sensor history",
|
||||
"history.allGreeIndoor": "All GREE indoor temperatures",
|
||||
"history.allGreeIndoorHint": "Indoor temperature reported by every GREE unit, independent of zones.",
|
||||
"history.allOutdoor": "Outdoor temperatures",
|
||||
"history.allOutdoorHint": "Outdoor values reported by GREE units plus the optional Home Assistant outdoor sensor.",
|
||||
"history.allZoneControl": "All zone control temperatures",
|
||||
"history.allZoneControlHint": "The temperature actually used by the thermostat in every zone.",
|
||||
"history.greeOutdoor": "GREE outdoor",
|
||||
"history.deviceIndoor": "GREE indoor temperature",
|
||||
"history.deviceIndoorHint": "Indoor sensor history for the selected unit or all units.",
|
||||
"history.deviceOutdoor": "GREE outdoor temperature",
|
||||
"history.deviceOutdoorHint": "Outdoor temperature reported by compatible GREE units. Units without this sensor are omitted.",
|
||||
"history.deviceTargets": "GREE target temperatures",
|
||||
"history.deviceTargetsHint": "Setpoints sent to each GREE unit over time.",
|
||||
"history.deviceTarget": "Device target",
|
||||
"history.indoorTemperature": "Indoor temperature",
|
||||
"history.haSensors": "Home Assistant temperatures",
|
||||
"history.haSensorsHint": "Room and outdoor temperature entities read from Home Assistant.",
|
||||
"history.customTitle": "Chart composer",
|
||||
"history.customDescription": "Mix any GREE, zone or Home Assistant temperature series. Save it locally or copy a shareable URL.",
|
||||
"history.addSeries": "Add series",
|
||||
"history.noCustomSeries": "Add at least one series to the chart.",
|
||||
"history.chartName": "Chart name",
|
||||
"history.copyLink": "Copy link",
|
||||
"history.series": "series",
|
||||
"history.noSavedCharts": "No saved custom charts yet.",
|
||||
"history.customChart": "Custom climate chart",
|
||||
"history.customChartHint": "Your selected data sources on one timeline.",
|
||||
"history.chartSaved": "Custom chart saved",
|
||||
"history.linkCopied": "Chart link copied",
|
||||
"toast.successTitle": "Done",
|
||||
"toast.errorTitle": "Something went wrong"
|
||||
}
|
||||
}
|
||||
|
||||
+48
-1
@@ -305,6 +305,53 @@
|
||||
"history.targetShort": "Cel",
|
||||
"history.sensor": "Jednostka wewnętrzna",
|
||||
"history.homeAssistant": "Home Assistant",
|
||||
"history.assist": "Wsparcie pogodowe"
|
||||
"history.assist": "Wsparcie pogodowe",
|
||||
"history.tabOverview": "Przegląd",
|
||||
"history.tabZones": "Strefy",
|
||||
"history.tabDevices": "Jednostki GREE",
|
||||
"history.tabSensors": "Czujniki HA",
|
||||
"history.tabCustom": "Własny wykres",
|
||||
"history.overviewHint": "Wszystkie pokoje, jednostki i dostępne pomiary zewnętrzne w jednym miejscu.",
|
||||
"history.customHint": "Zbuduj własny wykres z danych GREE, stref i Home Assistant.",
|
||||
"history.allDevices": "Wszystkie jednostki GREE",
|
||||
"history.allSensors": "Wszystkie czujniki HA",
|
||||
"history.haSensor": "Czujnik HA",
|
||||
"history.deviceSamples": "Próbki GREE",
|
||||
"history.zoneSamples": "Próbki stref",
|
||||
"history.haSamples": "Próbki HA",
|
||||
"history.greeHistory": "Historia odczytów urządzeń",
|
||||
"history.zoneHistory": "Historia termostatu",
|
||||
"history.haHistory": "Historia czujników zewnętrznych",
|
||||
"history.allGreeIndoor": "Temperatury wszystkich jednostek GREE",
|
||||
"history.allGreeIndoorHint": "Temperatura wewnętrzna raportowana przez każdą jednostkę GREE, niezależnie od stref.",
|
||||
"history.allOutdoor": "Temperatury zewnętrzne",
|
||||
"history.allOutdoorHint": "Temperatury zewnętrzne raportowane przez jednostki GREE oraz opcjonalny czujnik Home Assistant.",
|
||||
"history.allZoneControl": "Temperatury sterujące wszystkich stref",
|
||||
"history.allZoneControlHint": "Temperatura faktycznie używana przez termostat w każdej strefie.",
|
||||
"history.greeOutdoor": "GREE zewnętrzna",
|
||||
"history.deviceIndoor": "Temperatura wewnętrzna GREE",
|
||||
"history.deviceIndoorHint": "Historia czujnika wewnętrznego wybranej jednostki lub wszystkich jednostek.",
|
||||
"history.deviceOutdoor": "Temperatura zewnętrzna GREE",
|
||||
"history.deviceOutdoorHint": "Temperatura zewnętrzna raportowana przez kompatybilne jednostki GREE. Jednostki bez tego czujnika są pomijane.",
|
||||
"history.deviceTargets": "Nastawy jednostek GREE",
|
||||
"history.deviceTargetsHint": "Temperatury zadane wysyłane do jednostek GREE w czasie.",
|
||||
"history.deviceTarget": "Nastawa jednostki",
|
||||
"history.indoorTemperature": "Temperatura wewnętrzna",
|
||||
"history.haSensors": "Temperatury Home Assistant",
|
||||
"history.haSensorsHint": "Czujniki temperatury pokojowej i zewnętrznej odczytywane z Home Assistant.",
|
||||
"history.customTitle": "Kreator wykresu",
|
||||
"history.customDescription": "Połącz dowolne serie GREE, stref i Home Assistant. Zapisz je lokalnie albo skopiuj link.",
|
||||
"history.addSeries": "Dodaj serię",
|
||||
"history.noCustomSeries": "Dodaj przynajmniej jedną serię do wykresu.",
|
||||
"history.chartName": "Nazwa wykresu",
|
||||
"history.copyLink": "Kopiuj link",
|
||||
"history.series": "serie",
|
||||
"history.noSavedCharts": "Brak zapisanych własnych wykresów.",
|
||||
"history.customChart": "Własny wykres klimatu",
|
||||
"history.customChartHint": "Wybrane źródła danych na wspólnej osi czasu.",
|
||||
"history.chartSaved": "Własny wykres zapisany",
|
||||
"history.linkCopied": "Link do wykresu skopiowany",
|
||||
"toast.successTitle": "Gotowe",
|
||||
"toast.errorTitle": "Wystąpił błąd"
|
||||
}
|
||||
}
|
||||
|
||||
+178
-18
@@ -21,7 +21,7 @@ use crate::{
|
||||
engine,
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone, ZoneControlPatch},
|
||||
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -259,6 +259,7 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
|
||||
light: true,
|
||||
current_temperature: if input.simulated { Some(25.0) } else { None },
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: input.simulated,
|
||||
last_seen: if input.simulated { Some(now) } else { None },
|
||||
last_error: None,
|
||||
@@ -413,7 +414,7 @@ impl ZoneInput {
|
||||
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
||||
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
|
||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_override_until: None,
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None,
|
||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||
demand: false, last_action_at: None,
|
||||
created_at, updated_at: Utc::now(),
|
||||
@@ -444,6 +445,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
zone.control_temperature_source = existing.control_temperature_source;
|
||||
zone.active_preset = existing.active_preset;
|
||||
zone.manual_preset = existing.manual_preset;
|
||||
zone.manual_setpoint = existing.manual_setpoint;
|
||||
zone.manual_override_until = existing.manual_override_until;
|
||||
zone.effective_mode = existing.effective_mode;
|
||||
zone.effective_setpoint = existing.effective_setpoint;
|
||||
@@ -460,8 +462,10 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
|
||||
if let Some(value) = patch.setpoint {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
||||
zone.setpoint = (value * 2.0).round() / 2.0;
|
||||
zone.manual_preset = Some("custom".into());
|
||||
let value = (value * 2.0).round() / 2.0;
|
||||
zone.setpoint = value;
|
||||
zone.manual_setpoint = Some(value);
|
||||
zone.effective_setpoint = Some(value);
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
if let Some(value) = patch.mode.as_deref() {
|
||||
@@ -478,10 +482,12 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
match value {
|
||||
"auto" => {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
"comfort" | "sleep" | "away" | "custom" => {
|
||||
zone.manual_preset = Some(value.to_string());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
||||
@@ -489,6 +495,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
}
|
||||
if patch.clear_override.unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
if let Some(value) = patch.enabled { zone.enabled = value; }
|
||||
@@ -496,7 +503,7 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode,
|
||||
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
||||
"override_until": zone.manual_override_until, "enabled": zone.enabled
|
||||
}));
|
||||
@@ -531,9 +538,11 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
for zone in &mut zones {
|
||||
if input.preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(input.preset.clone());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
@@ -732,23 +741,174 @@ async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQue
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HistoryQuery { zone_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
|
||||
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
|
||||
let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
if let Some(zone_id) = zone_id {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::NotFound(format!("zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
let bucket_seconds = match hours {
|
||||
struct HistoryQuery {
|
||||
scope: Option<String>,
|
||||
zone_id: Option<String>,
|
||||
device_id: Option<String>,
|
||||
entity_id: Option<String>,
|
||||
hours: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
fn history_bucket_seconds(hours: i64) -> i64 {
|
||||
match hours {
|
||||
1..=6 => 30,
|
||||
7..=24 => 120,
|
||||
25..=168 => 600,
|
||||
_ => 1800,
|
||||
};
|
||||
let values = state.db.list_zone_history(zone_id, Utc::now() - ChronoDuration::hours(hours), bucket_seconds, query.limit.unwrap_or(12_000))?;
|
||||
Ok(Json(json!({"readings": values, "bucket_seconds": bucket_seconds})))
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec<Reading>) -> Vec<ZoneReading> {
|
||||
readings.into_iter().map(|reading| ZoneReading {
|
||||
id: reading.id,
|
||||
zone_id: zone.id.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
timestamp: reading.timestamp,
|
||||
gree_temperature: reading.indoor_temperature,
|
||||
external_temperature: None,
|
||||
control_temperature: reading.indoor_temperature,
|
||||
target_temperature: Some(reading.target_temperature),
|
||||
device_setpoint: Some(reading.target_temperature),
|
||||
outdoor_temperature: reading.outdoor_temperature,
|
||||
power: reading.power,
|
||||
mode: device.mode.clone(),
|
||||
fan_speed: device.fan_speed,
|
||||
demand: false,
|
||||
control_source: "gree_history_fallback".into(),
|
||||
active_preset: "history".into(),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn zone_history_with_fallback(
|
||||
state: &AppState,
|
||||
zone_id: Option<&str>,
|
||||
since: chrono::DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ZoneReading>, AppError> {
|
||||
let mut values = state.db.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?;
|
||||
if let Some(zone_id) = zone_id {
|
||||
if values.is_empty() {
|
||||
let zone = state.db.get_zone(zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
||||
if let Some(device) = state.db.get_device(&zone.device_id)? {
|
||||
let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?;
|
||||
values = fallback_zone_rows(&zone, &device, rows);
|
||||
}
|
||||
}
|
||||
return Ok(values);
|
||||
}
|
||||
|
||||
let existing: std::collections::HashSet<String> = values.iter().map(|row| row.zone_id.clone()).collect();
|
||||
for zone in state.db.list_zones()? {
|
||||
if existing.contains(&zone.id) { continue; }
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
|
||||
let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?;
|
||||
values.extend(fallback_zone_rows(&zone, &device, rows));
|
||||
}
|
||||
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
||||
if values.len() > limit as usize {
|
||||
let keep_from = values.len() - limit as usize;
|
||||
values.drain(0..keep_from);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn sensor_history_with_fallback(
|
||||
state: &AppState,
|
||||
since: chrono::DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
outdoor_entity: &str,
|
||||
) -> Result<Vec<HaReading>, AppError> {
|
||||
let mut values = state.db.list_ha_history(None, since.clone(), bucket_seconds, limit)?;
|
||||
let mut existing: std::collections::HashSet<String> = values.iter().map(|row| row.entity_id.clone()).collect();
|
||||
for zone in state.db.list_zones()? {
|
||||
let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; };
|
||||
if existing.contains(entity_id) { continue; }
|
||||
let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
|
||||
let mut added = false;
|
||||
for row in rows {
|
||||
if let Some(temperature) = row.external_temperature {
|
||||
values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: Some(zone.id.clone()), kind: "room".into(), timestamp: row.timestamp, temperature });
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if added { existing.insert(entity_id.to_string()); }
|
||||
}
|
||||
let outdoor_entity = outdoor_entity.trim();
|
||||
if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) {
|
||||
for zone in state.db.list_zones()? {
|
||||
let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
|
||||
let mut added = false;
|
||||
for row in rows {
|
||||
if let Some(temperature) = row.outdoor_temperature {
|
||||
values.push(HaReading { id: row.id, entity_id: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature });
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if added { break; }
|
||||
}
|
||||
}
|
||||
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
||||
if values.len() > limit as usize {
|
||||
let keep_from = values.len() - limit as usize;
|
||||
values.drain(0..keep_from);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
|
||||
let since = Utc::now() - ChronoDuration::hours(hours);
|
||||
let bucket_seconds = history_bucket_seconds(hours);
|
||||
let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000);
|
||||
let scope = query.scope.as_deref().unwrap_or("zones");
|
||||
let outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone();
|
||||
let (device_count, zone_count, ha_count) = state.db.history_counts()?;
|
||||
|
||||
match scope {
|
||||
"devices" => {
|
||||
let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
let readings = state.db.list_device_history(device_id, since.clone(), bucket_seconds, limit)?;
|
||||
Ok(Json(json!({
|
||||
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
"sensors" => {
|
||||
let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
let readings = if entity_id.is_some() { state.db.list_ha_history(entity_id, since.clone(), bucket_seconds, limit)? } else { sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)? };
|
||||
Ok(Json(json!({
|
||||
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
"overview" => {
|
||||
let zones = zone_history_with_fallback(&state, None, since.clone(), bucket_seconds, limit)?;
|
||||
let devices = state.db.list_device_history(None, since.clone(), bucket_seconds, limit)?;
|
||||
let sensors = sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)?;
|
||||
Ok(Json(json!({
|
||||
"scope": "overview", "bucket_seconds": bucket_seconds,
|
||||
"zones": zones, "devices": devices, "sensors": sensors,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
"zones" | "zone" => {
|
||||
let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
if let Some(zone_id) = zone_id {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::NotFound(format!("zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
let readings = zone_history_with_fallback(&state, zone_id, since.clone(), bucket_seconds, limit)?;
|
||||
Ok(Json(json!({
|
||||
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
_ => Err(AppError::BadRequest("history scope must be overview, zones, devices or sensors".into())),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
queries,
|
||||
};
|
||||
|
||||
@@ -219,6 +219,23 @@ impl Db {
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reading> {
|
||||
let timestamp: String = row.get(2)?;
|
||||
Ok(Reading {
|
||||
@@ -240,7 +257,8 @@ impl Db {
|
||||
let conn = self.lock()?;
|
||||
let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
Ok(device + zone)
|
||||
let ha = conn.execute(queries::PRUNE_HA_READINGS, [before.to_rfc3339()])? as u64;
|
||||
Ok(device + zone + ha)
|
||||
}
|
||||
|
||||
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
@@ -311,6 +329,60 @@ impl Db {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
queries::INSERT_HA_READING_IF_DUE,
|
||||
params![
|
||||
reading.entity_id,
|
||||
reading.zone_id,
|
||||
reading.kind,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.temperature,
|
||||
cutoff.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<HaReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(entity_id) = entity_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_ha_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<HaReading> {
|
||||
let timestamp: String = row.get(4)?;
|
||||
Ok(HaReading {
|
||||
id: row.get(0)?,
|
||||
entity_id: row.get(1)?,
|
||||
zone_id: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
temperature: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn history_counts(&self) -> Result<(u64, u64, u64)> {
|
||||
let conn = self.lock()?;
|
||||
let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64))
|
||||
}
|
||||
|
||||
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
@@ -399,7 +471,7 @@ impl Db {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{ApiTokenInfo, Device};
|
||||
use crate::models::{ApiTokenInfo, Device, HaReading, Reading};
|
||||
|
||||
#[test]
|
||||
fn sqlite_round_trip() {
|
||||
@@ -424,5 +496,19 @@ mod tests {
|
||||
assert_eq!(db.list_api_tokens().unwrap().len(), 1);
|
||||
assert!(db.delete_api_token(&access_token.id).unwrap());
|
||||
assert!(!db.api_token_exists("test-hash").unwrap());
|
||||
|
||||
let now = Utc::now();
|
||||
db.add_reading(&Reading {
|
||||
id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5),
|
||||
outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(),
|
||||
}).unwrap();
|
||||
assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
|
||||
db.add_ha_reading_if_due(&HaReading {
|
||||
id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(),
|
||||
timestamp: now.clone(), temperature: 22.1,
|
||||
}, 15).unwrap();
|
||||
assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
+86
-28
@@ -6,7 +6,7 @@ use tokio::time::sleep;
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone, ZoneReading},
|
||||
models::{Automation, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneReading},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -251,13 +251,23 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
|
||||
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
|
||||
// it only makes the active setpoint/fan a little more assertive in extreme weather.
|
||||
let outdoor_temperature = if settings.outdoor_assist_enabled && !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
|
||||
let outdoor_temperature = if !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
|
||||
match home_assistant::read_temperature(
|
||||
&state.http,
|
||||
&settings.home_assistant,
|
||||
Some(settings.home_assistant.outdoor_entity_id.trim()),
|
||||
).await {
|
||||
Ok(value) => Some(value),
|
||||
Ok(value) => {
|
||||
record_ha_history(
|
||||
state,
|
||||
settings.home_assistant.outdoor_entity_id.trim(),
|
||||
None,
|
||||
"outdoor",
|
||||
value,
|
||||
settings.poll_interval_seconds,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
|
||||
None
|
||||
@@ -273,12 +283,14 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
|
||||
}
|
||||
}
|
||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if !zone.enabled { continue; }
|
||||
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
|
||||
@@ -300,7 +312,12 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let device_temperature = device.current_temperature;
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await {
|
||||
Ok(value) => Some(value),
|
||||
Ok(value) => {
|
||||
if let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) {
|
||||
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()}));
|
||||
@@ -341,7 +358,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
@@ -353,7 +370,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.effective_setpoint = Some(target);
|
||||
|
||||
let Some(temp) = temperature else {
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
@@ -375,7 +392,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
|
||||
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
||||
// stop naturally when we move the target to the satisfied side of room temperature.
|
||||
let assist = outdoor_assist_offset(effective_mode, outdoor_temperature, temp, target);
|
||||
let assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
|
||||
let active_target = match effective_mode {
|
||||
"heat" => target + assist,
|
||||
_ => target - assist,
|
||||
@@ -388,7 +405,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.device_setpoint = Some(desired_device_target);
|
||||
|
||||
let desired_fan = if zone.smart_fan {
|
||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_temperature, zone.demand))
|
||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -424,26 +441,33 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) -> Result<()> {
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else { return Ok(()); };
|
||||
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) {
|
||||
let device = match state.db.get_device(&zone.device_id) {
|
||||
Ok(Some(device)) => device,
|
||||
Ok(None) => return,
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reading = ZoneReading {
|
||||
id: 0,
|
||||
zone_id: zone.id.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
gree_temperature: zone.device_temperature,
|
||||
gree_temperature: zone.device_temperature.or(device.current_temperature),
|
||||
external_temperature: zone.external_temperature,
|
||||
control_temperature: zone.current_temperature,
|
||||
target_temperature: zone.effective_setpoint,
|
||||
control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature),
|
||||
target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)),
|
||||
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
|
||||
outdoor_temperature,
|
||||
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
|
||||
power: device.power,
|
||||
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() },
|
||||
fan_speed: device.fan_speed,
|
||||
@@ -451,10 +475,32 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
active_preset: zone.active_preset.clone(),
|
||||
};
|
||||
// History is deliberately less frequent than the zone control loop to keep SQLite compact.
|
||||
let interval = poll_interval_seconds.max(15) as i64;
|
||||
state.db.add_zone_reading_if_due(&reading, interval)?;
|
||||
Ok(())
|
||||
if let Err(err) = state.db.add_zone_reading_if_due(&reading, interval) {
|
||||
tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample");
|
||||
}
|
||||
}
|
||||
|
||||
fn record_ha_history(
|
||||
state: &AppState,
|
||||
entity_id: &str,
|
||||
zone_id: Option<&str>,
|
||||
kind: &str,
|
||||
temperature: f64,
|
||||
poll_interval_seconds: u64,
|
||||
) {
|
||||
let reading = HaReading {
|
||||
id: 0,
|
||||
entity_id: entity_id.to_string(),
|
||||
zone_id: zone_id.map(str::to_string),
|
||||
kind: kind.to_string(),
|
||||
timestamp: Utc::now(),
|
||||
temperature,
|
||||
};
|
||||
let interval = poll_interval_seconds.max(15) as i64;
|
||||
if let Err(err) = state.db.add_ha_reading_if_due(&reading, interval) {
|
||||
tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample");
|
||||
}
|
||||
}
|
||||
|
||||
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
|
||||
@@ -534,21 +580,24 @@ fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
|
||||
}
|
||||
|
||||
fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) {
|
||||
if let Some(manual) = zone.manual_preset.as_deref() {
|
||||
return if manual == "custom" {
|
||||
let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() {
|
||||
if manual == "custom" {
|
||||
("custom".into(), zone.setpoint)
|
||||
} else {
|
||||
(manual.to_string(), profile_setpoint(zone, manual, mode))
|
||||
};
|
||||
}
|
||||
if let Some(item) = schedule {
|
||||
return if item.preset == "custom" {
|
||||
}
|
||||
} else if let Some(item) = schedule {
|
||||
if item.preset == "custom" {
|
||||
("custom".into(), item.setpoint)
|
||||
} else {
|
||||
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
|
||||
};
|
||||
}
|
||||
("comfort".into(), profile_setpoint(zone, "comfort", mode))
|
||||
}
|
||||
} else {
|
||||
("comfort".into(), profile_setpoint(zone, "comfort", mode))
|
||||
};
|
||||
// Quick +/- temperature adjustments are independent from the selected preset.
|
||||
// The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target.
|
||||
(preset, zone.manual_setpoint.unwrap_or(base_target))
|
||||
}
|
||||
|
||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||
@@ -667,7 +716,7 @@ mod tests {
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -708,6 +757,15 @@ mod tests {
|
||||
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_setpoint_keeps_active_preset() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.manual_setpoint = Some(22.5);
|
||||
let (preset, target) = resolve_zone_target(&zone, None, "cool");
|
||||
assert_eq!(preset, "comfort");
|
||||
assert_eq!(target, 22.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_zone_keeps_old_comfort_setpoint() {
|
||||
let mut zone = test_zone("device");
|
||||
|
||||
+18
-1
@@ -71,6 +71,9 @@ pub struct Device {
|
||||
pub current_temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
/// Some GREE firmware reports TemSen/OutEnvTem with a +40 C wire offset.
|
||||
#[serde(default)]
|
||||
pub temperature_sensor_offset: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub online: bool,
|
||||
#[serde(default)]
|
||||
@@ -110,6 +113,7 @@ impl Device {
|
||||
light: true,
|
||||
current_temperature: Some(26.0),
|
||||
outdoor_temperature: Some(30.0),
|
||||
temperature_sensor_offset: Some(false),
|
||||
online: true,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
@@ -228,9 +232,12 @@ pub struct Zone {
|
||||
/// Effective profile currently used by the zone: comfort/sleep/away/custom.
|
||||
#[serde(default = "default_active_preset")]
|
||||
pub active_preset: String,
|
||||
/// Optional user override. Cleared automatically at the next schedule boundary.
|
||||
/// Optional user preset override. Cleared automatically at the next schedule boundary.
|
||||
#[serde(default)]
|
||||
pub manual_preset: Option<String>,
|
||||
/// Optional quick-thermostat setpoint override. It does not change the active preset.
|
||||
#[serde(default)]
|
||||
pub manual_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub manual_override_until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
@@ -343,6 +350,16 @@ pub struct ZoneReading {
|
||||
pub active_preset: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HaReading {
|
||||
pub id: i64,
|
||||
pub entity_id: String,
|
||||
pub zone_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub temperature: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventLog {
|
||||
pub id: i64,
|
||||
|
||||
+26
-5
@@ -217,6 +217,7 @@ impl GreeClient {
|
||||
light: true,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: true,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
@@ -302,14 +303,23 @@ impl GreeClient {
|
||||
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem"
|
||||
];
|
||||
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"];
|
||||
let response = match self.status_request(device, key, &full_cols).await {
|
||||
Ok(value) => value,
|
||||
let (response, used_core_fallback) = match self.status_request(device, key, &full_cols).await {
|
||||
Ok(value) => (value, false),
|
||||
Err(first) => {
|
||||
tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties");
|
||||
self.status_request(device, key, &core_cols).await?
|
||||
(self.status_request(device, key, &core_cols).await?, true)
|
||||
}
|
||||
};
|
||||
self.apply_status(device, &response)?;
|
||||
// Some firmware rejects a large mixed property list but still exposes OutEnvTem.
|
||||
// Probe it separately after the core fallback so compatible units can contribute
|
||||
// their outdoor sensor to history without making the main poll fail.
|
||||
if used_core_fallback {
|
||||
match self.status_request(device, key, &["OutEnvTem"]).await {
|
||||
Ok(optional) => { let _ = self.apply_status(device, &optional); }
|
||||
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
|
||||
}
|
||||
}
|
||||
device.online = true;
|
||||
device.communication_failures = 0;
|
||||
device.last_seen = Some(Utc::now());
|
||||
@@ -343,11 +353,22 @@ impl GreeClient {
|
||||
"Lig" => device.light = value_as_i64(value) != 0,
|
||||
"TemSen" => {
|
||||
let raw = value_as_f64(value);
|
||||
device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
|
||||
// The room sensor is a useful discriminator because normal indoor
|
||||
// temperatures cannot exceed 40 C in controller operation. Persist
|
||||
// the detected wire format and reuse it for OutEnvTem, including
|
||||
// sub-zero outdoor values encoded as (temperature + 40).
|
||||
if raw != 0.0 {
|
||||
let offset = raw > 40.0;
|
||||
device.temperature_sensor_offset = Some(offset);
|
||||
device.current_temperature = Some(if offset { raw - 40.0 } else { raw });
|
||||
}
|
||||
}
|
||||
"OutEnvTem" => {
|
||||
let raw = value_as_f64(value);
|
||||
device.outdoor_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
|
||||
if raw != 0.0 {
|
||||
let offset = device.temperature_sensor_offset.unwrap_or(raw > 50.0);
|
||||
device.outdoor_temperature = Some(if offset { raw - 40.0 } else { raw });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,19 @@ CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx
|
||||
CREATE INDEX IF NOT EXISTS zone_readings_time_idx
|
||||
ON zone_readings(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ha_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id TEXT NOT NULL,
|
||||
zone_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
temperature REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ha_readings_entity_time_idx
|
||||
ON ha_readings(entity_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS ha_readings_zone_time_idx
|
||||
ON ha_readings(zone_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
@@ -110,6 +123,8 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
|
||||
@@ -198,6 +213,26 @@ ORDER BY timestamp ASC
|
||||
LIMIT ?2
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
|
||||
AVG(target_temperature),MAX(power),MAX(source)
|
||||
FROM readings
|
||||
WHERE device_id=?1 AND timestamp>=?2
|
||||
GROUP BY device_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICE_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
|
||||
AVG(target_temperature),MAX(power),MAX(source)
|
||||
FROM readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY device_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
|
||||
|
||||
pub const INSERT_ZONE_READING_IF_DUE: &str = r#"
|
||||
@@ -243,6 +278,43 @@ pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHE
|
||||
pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1";
|
||||
pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1";
|
||||
|
||||
pub const INSERT_HA_READING_IF_DUE: &str = r#"
|
||||
INSERT INTO ha_readings(entity_id,zone_id,kind,timestamp,temperature)
|
||||
SELECT ?1,?2,?3,?4,?5
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ha_readings
|
||||
WHERE entity_id=?1 AND COALESCE(zone_id,'')=COALESCE(?2,'') AND kind=?3 AND timestamp>=?6
|
||||
LIMIT 1
|
||||
)
|
||||
"#;
|
||||
|
||||
pub const LIST_HA_HISTORY_BY_ENTITY_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
|
||||
FROM ha_readings
|
||||
WHERE entity_id=?1 AND timestamp>=?2
|
||||
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_HA_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
|
||||
FROM ha_readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const PRUNE_HA_READINGS: &str = "DELETE FROM ha_readings WHERE timestamp < ?1";
|
||||
|
||||
pub const HISTORY_COUNTS: &str = r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM readings),
|
||||
(SELECT COUNT(*) FROM zone_readings),
|
||||
(SELECT COUNT(*) FROM ha_readings)
|
||||
"#;
|
||||
|
||||
pub const INSERT_EVENT: &str =
|
||||
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const LIST_EVENTS: &str =
|
||||
|
||||
+364
-109
@@ -16,8 +16,12 @@ const app = {
|
||||
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {}, historyReadings: [], historyZone: 'all',
|
||||
languages: [], translations: {}, locales: {},
|
||||
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
|
||||
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
|
||||
customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {},
|
||||
};
|
||||
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
|
||||
|
||||
const $ = (selector, root = document) => root.querySelector(selector);
|
||||
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
|
||||
@@ -143,11 +147,15 @@ async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
function toast(message, error = false) {
|
||||
const node = $('#toast');
|
||||
node.textContent = message;
|
||||
node.className = error ? 'show error' : 'show';
|
||||
clearTimeout(node._timer);
|
||||
node._timer = setTimeout(() => node.className = '', 3200);
|
||||
const host = $('#toastStack'); if (!host) return;
|
||||
const item = document.createElement('div');
|
||||
item.className = `toast-item ${error ? 'error' : 'success'}`;
|
||||
item.innerHTML = `<span class="toast-icon">${error ? '!' : '✓'}</span><div class="toast-copy"><strong>${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}</strong><span>${esc(message)}</span></div><button class="toast-close" type="button" aria-label="Close">×</button><i class="toast-progress"></i>`;
|
||||
host.appendChild(item);
|
||||
requestAnimationFrame(() => item.classList.add('show'));
|
||||
const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); };
|
||||
item.querySelector('.toast-close').addEventListener('click', remove);
|
||||
item._timer = setTimeout(remove, error ? 5200 : 3600);
|
||||
}
|
||||
|
||||
function showTokenDialog() {
|
||||
@@ -341,12 +349,6 @@ function fillSelects() {
|
||||
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
||||
const presetZone = $('#schedulePresetZone');
|
||||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
|
||||
const history = $('#historyZone');
|
||||
if (history) {
|
||||
const historyCurrent = history.value || 'all';
|
||||
history.innerHTML = `<option value="all">${esc(tr('history.allZones'))}</option>${zoneOptions}`;
|
||||
if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccessTokens() {
|
||||
@@ -383,15 +385,64 @@ function formatDuration(seconds) {
|
||||
return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
function showView(name) {
|
||||
const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', settings:'/settings', logs:'/events'};
|
||||
const HISTORY_TABS = ['overview','zones','devices','sensors','custom'];
|
||||
|
||||
function currentHistoryPath() {
|
||||
const tab = HISTORY_TABS.includes(app.historyTab) ? app.historyTab : 'overview';
|
||||
const params = new URLSearchParams();
|
||||
const hours = $('#historyHours')?.value || new URLSearchParams(location.search).get('hours') || '24';
|
||||
if (hours !== '24') params.set('hours', hours);
|
||||
if (tab === 'zones' && app.historyZone !== 'all') params.set('zone', app.historyZone);
|
||||
if (tab === 'devices' && app.historyDevice !== 'all') params.set('device', app.historyDevice);
|
||||
if (tab === 'sensors' && app.historySensor !== 'all') params.set('sensor', app.historySensor);
|
||||
if (tab === 'custom' && app.customChartSeries.length) params.set('chart', encodeChartSpec(app.customChartSeries));
|
||||
const query = params.toString();
|
||||
return `/history/${tab}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
function updateBrowserUrl(path, replace=false) {
|
||||
const current = `${location.pathname}${location.search}`;
|
||||
if (current === path) return;
|
||||
history[replace ? 'replaceState' : 'pushState']({}, '', path);
|
||||
}
|
||||
|
||||
function showView(name, {push=true, scroll=true}={}) {
|
||||
app.currentView = name;
|
||||
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
|
||||
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name))));
|
||||
window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
if (name === 'history' && app.zones.length) loadHistory();
|
||||
if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard'));
|
||||
if (scroll) window.scrollTo({top: 0, behavior: 'smooth'});
|
||||
if (name === 'history') { renderHistoryNavigation(); loadHistory(); }
|
||||
if (name === 'logs') loadLogs();
|
||||
}
|
||||
|
||||
function showHistoryTab(tab, {push=true, load=true}={}) {
|
||||
app.historyTab = HISTORY_TABS.includes(tab) ? tab : 'overview';
|
||||
renderHistoryNavigation();
|
||||
if (push) updateBrowserUrl(currentHistoryPath());
|
||||
if (load) loadHistory();
|
||||
}
|
||||
|
||||
function applyRouteFromLocation() {
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
const first = parts[0] || 'dashboard';
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (first === 'history') {
|
||||
app.historyTab = HISTORY_TABS.includes(parts[1]) ? parts[1] : 'overview';
|
||||
app.historyZone = params.get('zone') || 'all';
|
||||
app.historyDevice = params.get('device') || 'all';
|
||||
app.historySensor = params.get('sensor') || 'all';
|
||||
const hours = params.get('hours');
|
||||
if (hours && ['6','24','168','720'].includes(hours) && $('#historyHours')) $('#historyHours').value = hours;
|
||||
if (app.historyTab === 'custom' && params.get('chart')) app.customChartSeries = decodeChartSpec(params.get('chart'));
|
||||
showView('history', {push:false, scroll:false});
|
||||
return;
|
||||
}
|
||||
const reverse = Object.entries(VIEW_ROUTES).find(([,path]) => path === `/${first}`);
|
||||
showView(reverse?.[0] || 'dashboard', {push:false, scroll:false});
|
||||
}
|
||||
|
||||
async function sendDeviceCommand(id, command) {
|
||||
try {
|
||||
const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command});
|
||||
@@ -405,12 +456,27 @@ function updateDevice(device) {
|
||||
}
|
||||
|
||||
async function sendZoneControl(id, patch) {
|
||||
const sequence = (app.zoneControlSeq[id] || 0) + 1;
|
||||
app.zoneControlSeq[id] = sequence;
|
||||
try {
|
||||
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch});
|
||||
if (app.zoneControlSeq[id] !== sequence) return;
|
||||
const index = app.zones.findIndex(item => item.id === zone.id);
|
||||
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
|
||||
renderSummary(); renderZones();
|
||||
} catch (error) { toast(error.message, true); }
|
||||
} catch (error) {
|
||||
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
|
||||
}
|
||||
}
|
||||
|
||||
function queueZoneTemperature(zone, value) {
|
||||
const next = Math.round(clamp(value, 8, 30) * 2) / 2;
|
||||
zone.manual_setpoint = next;
|
||||
zone.setpoint = next;
|
||||
zone.effective_setpoint = next;
|
||||
renderZones();
|
||||
clearTimeout(app.zoneTemperatureTimers[zone.id]);
|
||||
app.zoneTemperatureTimers[zone.id] = setTimeout(() => sendZoneControl(zone.id, {setpoint: next}), 160);
|
||||
}
|
||||
|
||||
function showDiscoveryNames(ids) {
|
||||
@@ -496,16 +562,68 @@ function populateAutomation(id) {
|
||||
openDialog('automationDialog');
|
||||
}
|
||||
|
||||
function encodeChartSpec(series) {
|
||||
try {
|
||||
const raw = JSON.stringify(series || []);
|
||||
return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function decodeChartSpec(encoded) {
|
||||
try {
|
||||
const padded = String(encoded || '').replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '=');
|
||||
const value = JSON.parse(atob(padded));
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : [];
|
||||
} catch (_) { return []; }
|
||||
}
|
||||
|
||||
function historyEntityOptions() {
|
||||
const zones = app.zones.map(zone => `<option value="${esc(zone.id)}">${esc(zone.name)}</option>`).join('');
|
||||
const devices = app.devices.map(device => `<option value="${esc(device.id)}">${esc(device.name)}</option>`).join('');
|
||||
const entities = [...new Set([
|
||||
...app.historyData.sensors.map(row => row.entity_id),
|
||||
...app.zones.map(zone => zone.ha_entity_id).filter(Boolean),
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
].filter(Boolean))].sort();
|
||||
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(entity)}</option>`).join('');
|
||||
return {zones, devices, sensors, entities};
|
||||
}
|
||||
|
||||
function renderHistoryNavigation() {
|
||||
$$('#historyTabs [data-history-tab]').forEach(button => button.classList.toggle('active', button.dataset.historyTab === app.historyTab));
|
||||
const host = $('#historyContextControls'); if (!host) return;
|
||||
const options = historyEntityOptions();
|
||||
if (app.historyTab === 'zones') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
|
||||
const select=$('#historyZoneSelect'); if ([...select.options].some(option=>option.value===app.historyZone)) select.value=app.historyZone;
|
||||
} else if (app.historyTab === 'devices') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
|
||||
const select=$('#historyDeviceSelect'); if ([...select.options].some(option=>option.value===app.historyDevice)) select.value=app.historyDevice;
|
||||
} else if (app.historyTab === 'sensors') {
|
||||
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
|
||||
const select=$('#historySensorSelect'); if ([...select.options].some(option=>option.value===app.historySensor)) select.value=app.historySensor;
|
||||
} else if (app.historyTab === 'custom') {
|
||||
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.customHint'))}</span>`;
|
||||
} else {
|
||||
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.overviewHint'))}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const zoneId = $('#historyZone')?.value || 'all';
|
||||
if (app.historyLoading) return;
|
||||
app.historyLoading = true;
|
||||
const hours = $('#historyHours')?.value || '24';
|
||||
try {
|
||||
const data = await api(`/api/history?zone_id=${encodeURIComponent(zoneId)}&hours=${encodeURIComponent(hours)}&limit=12000`);
|
||||
const readings = data.readings || [];
|
||||
app.historyReadings = readings; app.historyZone = zoneId;
|
||||
renderHistorySummary(readings, zoneId);
|
||||
drawHistoryCharts(readings, zoneId);
|
||||
} catch (error) { toast(error.message, true); }
|
||||
const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`);
|
||||
app.historyData = {zones:data.zones || [], devices:data.devices || [], sensors:data.sensors || []};
|
||||
app.historyCounts = data.counts || {};
|
||||
renderHistoryNavigation();
|
||||
renderHistoryPage();
|
||||
if (app.currentView === 'history') updateBrowserUrl(currentHistoryPath(), true);
|
||||
} catch (error) {
|
||||
toast(error.message, true);
|
||||
renderHistoryPage();
|
||||
} finally { app.historyLoading = false; }
|
||||
}
|
||||
|
||||
const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue'];
|
||||
@@ -515,38 +633,6 @@ function cssColor(name, fallback) {
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
function latestByZone(readings) {
|
||||
const map = new Map();
|
||||
readings.forEach(row => map.set(row.zone_id, row));
|
||||
return map;
|
||||
}
|
||||
|
||||
function renderHistorySummary(readings, zoneId) {
|
||||
const host = $('#historySummary'); if (!host) return;
|
||||
if (!readings.length) {
|
||||
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('history.noData'))}</strong></div>`;
|
||||
return;
|
||||
}
|
||||
if (zoneId === 'all') {
|
||||
const latest = latestByZone(readings);
|
||||
host.innerHTML = app.zones.filter(z => latest.has(z.id)).map(zone => {
|
||||
const row = latest.get(zone.id);
|
||||
return `<div class="history-stat"><small>${esc(zone.name)}</small><strong>${fmtTemp(row.control_temperature)}</strong><span>${esc(tr('history.targetShort'))} ${fmtTemp(row.target_temperature)}</span></div>`;
|
||||
}).join('');
|
||||
return;
|
||||
}
|
||||
const row = readings[readings.length - 1];
|
||||
const cards = [
|
||||
[tr('history.greeSensor'), fmtTemp(row.gree_temperature), tr('history.sensor')],
|
||||
[tr('history.roomSensor'), fmtTemp(row.external_temperature), tr('history.homeAssistant')],
|
||||
[tr('history.controlTemperature'), fmtTemp(row.control_temperature), row.control_source || '—'],
|
||||
[tr('history.comfortTarget'), fmtTemp(row.target_temperature), row.active_preset || '—'],
|
||||
[tr('history.deviceSetpoint'), fmtTemp(row.device_setpoint), (row.mode || '—').toUpperCase()],
|
||||
[tr('history.outdoorTemperature'), fmtTemp(row.outdoor_temperature), tr('history.assist')],
|
||||
];
|
||||
host.innerHTML = cards.map(([label,value,detail]) => `<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function timeLabel(ts, hours) {
|
||||
const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'};
|
||||
return new Date(ts).toLocaleString(locale(), options);
|
||||
@@ -564,6 +650,7 @@ function prepareCanvas(canvas, height) {
|
||||
}
|
||||
|
||||
function drawEmptyChart(canvas, height=340) {
|
||||
if (!canvas) return;
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center';
|
||||
ctx.fillText(tr('history.noData'), width/2, height/2);
|
||||
@@ -571,17 +658,18 @@ function drawEmptyChart(canvas, height=340) {
|
||||
|
||||
function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) {
|
||||
if (!canvas || !rows.length || !series.length) return drawEmptyChart(canvas, height);
|
||||
const sortedRows = [...rows].sort((a,b)=>new Date(a.timestamp)-new Date(b.timestamp));
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333'), panel=cssColor('--surface','#111');
|
||||
const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333');
|
||||
const pad={left:54,right:20,top:20,bottom:42};
|
||||
const allValues=[];
|
||||
series.forEach(s => rows.forEach(r => { const v=s.value(r); if(Number.isFinite(v)) allValues.push(v); }));
|
||||
series.forEach(item => sortedRows.forEach(row => { const value=item.value(row); if(Number.isFinite(value)) allValues.push(value); }));
|
||||
if (!allValues.length) return drawEmptyChart(canvas,height);
|
||||
let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue;
|
||||
let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue;
|
||||
if (max-min < 2) { min-=1; max+=1; }
|
||||
const firstTs = new Date(rows[0].timestamp).getTime();
|
||||
const lastTs = new Date(rows[rows.length-1].timestamp).getTime();
|
||||
const firstTs = new Date(sortedRows[0].timestamp).getTime();
|
||||
const lastTs = new Date(sortedRows[sortedRows.length-1].timestamp).getTime();
|
||||
const span = Math.max(1,lastTs-firstTs);
|
||||
const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right);
|
||||
const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom);
|
||||
@@ -593,76 +681,232 @@ function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValu
|
||||
}
|
||||
const ticks=5;
|
||||
for(let i=0;i<=ticks;i++){
|
||||
const idx=Math.min(rows.length-1,Math.round(i*(rows.length-1)/ticks)); const px=x(rows[idx]);
|
||||
ctx.textAlign='center'; ctx.fillText(timeLabel(rows[idx].timestamp,$('#historyHours')?.value),px,height-14);
|
||||
const idx=Math.min(sortedRows.length-1,Math.round(i*(sortedRows.length-1)/ticks)); const px=x(sortedRows[idx]);
|
||||
ctx.textAlign='center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp,$('#historyHours')?.value),px,height-14);
|
||||
}
|
||||
series.forEach(s => {
|
||||
ctx.beginPath(); ctx.strokeStyle=s.color; ctx.lineWidth=s.width||2; ctx.setLineDash(s.dash||[]);
|
||||
series.forEach(item => {
|
||||
ctx.beginPath(); ctx.strokeStyle=item.color; ctx.lineWidth=item.width||2; ctx.setLineDash(item.dash||[]);
|
||||
let started=false, prev=null;
|
||||
rows.forEach(row => {
|
||||
const value=s.value(row); if(!Number.isFinite(value)) return;
|
||||
sortedRows.forEach(row => {
|
||||
const value=item.value(row); if(!Number.isFinite(value)) return;
|
||||
const px=x(row),py=y(value);
|
||||
if(!started||prev===null){ctx.moveTo(px,py);started=true;}
|
||||
else if(s.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py);
|
||||
else if(item.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py);
|
||||
prev=value;
|
||||
});
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
});
|
||||
canvas._history={rows,series};
|
||||
}
|
||||
|
||||
function renderLegend(host, series) {
|
||||
if (!host) return;
|
||||
host.innerHTML=series.map(s=>`<span><i class="legend-line" style="--legend-color:${esc(s.color)}"></i>${esc(s.label)}</span>`).join('');
|
||||
host.innerHTML=series.map(item=>`<span><i class="legend-line" style="--legend-color:${esc(item.color)}"></i>${esc(item.label)}</span>`).join('');
|
||||
}
|
||||
|
||||
function drawHistoryCharts(readings, zoneId) {
|
||||
const tempCanvas=$('#historyTemperatureChart'), opCanvas=$('#historyOperationChart');
|
||||
if (!readings.length) {
|
||||
drawEmptyChart(tempCanvas,360); drawEmptyChart(opCanvas,260);
|
||||
$('#historyTemperatureLegend').innerHTML=''; $('#historyOperationLegend').innerHTML=''; return;
|
||||
}
|
||||
if (zoneId === 'all') {
|
||||
const zones=app.zones.filter(z=>readings.some(r=>r.zone_id===z.id));
|
||||
const rows=readings;
|
||||
const tempSeries=zones.map((zone,index)=>({
|
||||
label:zone.name,
|
||||
color:cssColor(HISTORY_COLORS[index%HISTORY_COLORS.length],`hsl(${index*67%360} 70% 55%)`),
|
||||
value:r=>r.zone_id===zone.id?historyNumber(r.control_temperature):NaN,
|
||||
}));
|
||||
const outdoorColor=cssColor('--muted-strong','#999');
|
||||
tempSeries.push({label:tr('history.outdoorTemperature'),color:outdoorColor,dash:[6,5],value:r=>historyNumber(r.outdoor_temperature)});
|
||||
drawLineChart(tempCanvas,tempSeries,rows,{height:360}); renderLegend($('#historyTemperatureLegend'),tempSeries);
|
||||
function historyChartMarkup(id, title, hint, compact=false) {
|
||||
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p></div></div><div class="chart-wrap ${compact?'compact-chart':''}"><canvas id="${esc(id)}" width="1000" height="${compact?300:420}"></canvas></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
|
||||
}
|
||||
|
||||
const targetSeries=zones.map((zone,index)=>({
|
||||
label:`${zone.name} · ${tr('history.targetShort')}`,
|
||||
color:cssColor(HISTORY_COLORS[index%HISTORY_COLORS.length],`hsl(${index*67%360} 70% 55%)`),
|
||||
dash:[5,4], value:r=>r.zone_id===zone.id?historyNumber(r.target_temperature):NaN,
|
||||
}));
|
||||
drawLineChart(opCanvas,targetSeries,rows,{height:260}); renderLegend($('#historyOperationLegend'),targetSeries);
|
||||
function historySeriesColor(index) {
|
||||
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index*67)%360} 68% 55%)`);
|
||||
}
|
||||
|
||||
function renderHistorySummary() {
|
||||
const host=$('#historySummary'); if(!host) return;
|
||||
const deviceRows=app.historyData.devices, zoneRows=app.historyData.zones, sensorRows=app.historyData.sensors;
|
||||
const cards=[
|
||||
[tr('history.deviceSamples'), app.historyCounts.devices ?? deviceRows.length, tr('history.greeHistory')],
|
||||
[tr('history.zoneSamples'), app.historyCounts.zones ?? zoneRows.length, tr('history.zoneHistory')],
|
||||
[tr('history.haSamples'), app.historyCounts.ha ?? sensorRows.length, tr('history.haHistory')],
|
||||
];
|
||||
host.innerHTML=cards.map(([label,value,detail])=>`<div class="history-stat"><small>${esc(label)}</small><strong>${esc(value)}</strong><span>${esc(detail)}</span></div>`).join('');
|
||||
}
|
||||
|
||||
function renderOverviewHistory() {
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML = historyChartMarkup('overviewIndoorChart',tr('history.allGreeIndoor'),tr('history.allGreeIndoorHint'))
|
||||
+ historyChartMarkup('overviewOutdoorChart',tr('history.allOutdoor'),tr('history.allOutdoorHint'))
|
||||
+ historyChartMarkup('overviewZonesChart',tr('history.allZoneControl'),tr('history.allZoneControlHint'));
|
||||
|
||||
const deviceRows=app.historyData.devices;
|
||||
const indoorSeries=app.devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.indoor_temperature):NaN}));
|
||||
drawLineChart($('#overviewIndoorChart'),indoorSeries,deviceRows,{height:360}); renderLegend($('#overviewIndoorChartLegend'),indoorSeries);
|
||||
|
||||
const outdoorDeviceSeries=app.devices.filter(device=>deviceRows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:`${device.name} · ${tr('history.greeOutdoor')}`,color:historySeriesColor(index),value:row=>!row.zone_id&&!row.entity_id&&row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
|
||||
const outdoorEntities=[...new Set(app.historyData.sensors.filter(row=>row.kind==='outdoor').map(row=>row.entity_id))];
|
||||
const outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${entity}`,color:historySeriesColor(index+outdoorDeviceSeries.length),dash:[6,4],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
const outdoorRows=[...deviceRows,...app.historyData.sensors.filter(row=>row.kind==='outdoor')];
|
||||
const outdoorSeries=[...outdoorDeviceSeries,...outdoorHaSeries];
|
||||
drawLineChart($('#overviewOutdoorChart'),outdoorSeries,outdoorRows,{height:320}); renderLegend($('#overviewOutdoorChartLegend'),outdoorSeries);
|
||||
|
||||
const zoneRows=app.historyData.zones;
|
||||
const zoneSeries=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN}));
|
||||
drawLineChart($('#overviewZonesChart'),zoneSeries,zoneRows,{height:340}); renderLegend($('#overviewZonesChartLegend'),zoneSeries);
|
||||
}
|
||||
|
||||
function renderZoneHistory() {
|
||||
const selected=app.historyZone;
|
||||
const rows=selected==='all'?app.historyData.zones:app.historyData.zones.filter(row=>row.zone_id===selected);
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('zoneTemperatureChart',tr('history.temperatureOverview'),tr('history.temperatureOverviewHint'))+historyChartMarkup('zoneOperationChart',tr('history.operationOverview'),tr('history.operationOverviewHint'),true);
|
||||
if(selected==='all'){
|
||||
const series=app.zones.map((zone,index)=>({label:zone.name,color:historySeriesColor(index),value:row=>row.zone_id===zone.id?historyNumber(row.control_temperature):NaN}));
|
||||
drawLineChart($('#zoneTemperatureChart'),series,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),series);
|
||||
const targets=app.zones.map((zone,index)=>({label:`${zone.name} · ${tr('history.targetShort')}`,color:historySeriesColor(index),dash:[5,4],value:row=>row.zone_id===zone.id?historyNumber(row.target_temperature):NaN}));
|
||||
drawLineChart($('#zoneOperationChart'),targets,rows,{height:260}); renderLegend($('#zoneOperationChartLegend'),targets);
|
||||
return;
|
||||
}
|
||||
|
||||
const temperatureSeries=[
|
||||
{label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:r=>historyNumber(r.gree_temperature)},
|
||||
{label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:r=>historyNumber(r.external_temperature)},
|
||||
{label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:r=>historyNumber(r.control_temperature)},
|
||||
{label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:r=>historyNumber(r.target_temperature)},
|
||||
{label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:r=>historyNumber(r.device_setpoint)},
|
||||
{label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:r=>historyNumber(r.outdoor_temperature)},
|
||||
{label:tr('history.greeSensor'),color:cssColor('--accent','#3ecf8e'),value:row=>historyNumber(row.gree_temperature)},
|
||||
{label:tr('history.roomSensor'),color:cssColor('--info','#60a5fa'),value:row=>historyNumber(row.external_temperature)},
|
||||
{label:tr('history.controlTemperature'),color:cssColor('--teal','#2dd4bf'),width:2.8,value:row=>historyNumber(row.control_temperature)},
|
||||
{label:tr('history.comfortTarget'),color:cssColor('--warning','#f59e0b'),dash:[7,5],value:row=>historyNumber(row.target_temperature)},
|
||||
{label:tr('history.deviceSetpoint'),color:cssColor('--purple','#a78bfa'),dash:[3,4],value:row=>historyNumber(row.device_setpoint)},
|
||||
{label:tr('history.outdoorTemperature'),color:cssColor('--muted-strong','#9ca3af'),dash:[2,5],value:row=>historyNumber(row.outdoor_temperature)},
|
||||
];
|
||||
drawLineChart(tempCanvas,temperatureSeries,readings,{height:360}); renderLegend($('#historyTemperatureLegend'),temperatureSeries);
|
||||
|
||||
drawLineChart($('#zoneTemperatureChart'),temperatureSeries,rows,{height:360}); renderLegend($('#zoneTemperatureChartLegend'),temperatureSeries);
|
||||
const operationSeries=[
|
||||
{label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:r=>historyNumber(r.fan_speed)},
|
||||
{label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:r=>r.demand?4.5:0.5},
|
||||
{label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:r=>r.power?3.5:0.5},
|
||||
{label:tr('history.fanSpeed'),color:cssColor('--info','#60a5fa'),step:true,value:row=>historyNumber(row.fan_speed)},
|
||||
{label:tr('history.demand'),color:cssColor('--accent','#3ecf8e'),step:true,width:2.4,value:row=>row.demand?4.5:0.5},
|
||||
{label:tr('history.power'),color:cssColor('--warning','#f59e0b'),step:true,dash:[5,4],value:row=>row.power?3.5:0.5},
|
||||
];
|
||||
drawLineChart(opCanvas,operationSeries,readings,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#historyOperationLegend'),operationSeries);
|
||||
drawLineChart($('#zoneOperationChart'),operationSeries,rows,{height:260,minValue:0,maxValue:5,binaryLabels:true}); renderLegend($('#zoneOperationChartLegend'),operationSeries);
|
||||
}
|
||||
|
||||
function renderDeviceHistory() {
|
||||
const selected=app.historyDevice;
|
||||
const rows=selected==='all'?app.historyData.devices:app.historyData.devices.filter(row=>row.device_id===selected);
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('deviceIndoorChart',tr('history.deviceIndoor'),tr('history.deviceIndoorHint'))+historyChartMarkup('deviceOutdoorChart',tr('history.deviceOutdoor'),tr('history.deviceOutdoorHint'))+historyChartMarkup('deviceTargetChart',tr('history.deviceTargets'),tr('history.deviceTargetsHint'),true);
|
||||
const devices=selected==='all'?app.devices:app.devices.filter(device=>device.id===selected);
|
||||
const indoor=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.indoor_temperature):NaN}));
|
||||
const outdoor=devices.filter(device=>rows.some(row=>row.device_id===device.id&&Number.isFinite(historyNumber(row.outdoor_temperature)))).map((device,index)=>({label:device.name,color:historySeriesColor(index),value:row=>row.device_id===device.id?historyNumber(row.outdoor_temperature):NaN}));
|
||||
const targets=devices.map((device,index)=>({label:device.name,color:historySeriesColor(index),dash:[6,4],value:row=>row.device_id===device.id?historyNumber(row.target_temperature):NaN}));
|
||||
drawLineChart($('#deviceIndoorChart'),indoor,rows,{height:350}); renderLegend($('#deviceIndoorChartLegend'),indoor);
|
||||
drawLineChart($('#deviceOutdoorChart'),outdoor,rows,{height:310}); renderLegend($('#deviceOutdoorChartLegend'),outdoor);
|
||||
drawLineChart($('#deviceTargetChart'),targets,rows,{height:260}); renderLegend($('#deviceTargetChartLegend'),targets);
|
||||
}
|
||||
|
||||
function renderSensorHistory() {
|
||||
const selected=app.historySensor;
|
||||
const rows=selected==='all'?app.historyData.sensors:app.historyData.sensors.filter(row=>row.entity_id===selected);
|
||||
const entities=[...new Set(rows.map(row=>row.entity_id))];
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('haSensorsChart',tr('history.haSensors'),tr('history.haSensorsHint'));
|
||||
const series=entities.map((entity,index)=>({label:entity,color:historySeriesColor(index),dash:rows.some(row=>row.entity_id===entity&&row.kind==='outdoor')?[6,4]:[],value:row=>row.entity_id===entity?historyNumber(row.temperature):NaN}));
|
||||
drawLineChart($('#haSensorsChart'),series,rows,{height:370}); renderLegend($('#haSensorsChartLegend'),series);
|
||||
}
|
||||
|
||||
function customSeriesOptions() {
|
||||
const items=[];
|
||||
app.devices.forEach(device=>{
|
||||
items.push([`device|${device.id}|indoor`,`${device.name} · ${tr('history.indoorTemperature')}`]);
|
||||
items.push([`device|${device.id}|outdoor`,`${device.name} · ${tr('history.greeOutdoor')}`]);
|
||||
items.push([`device|${device.id}|target`,`${device.name} · ${tr('history.deviceTarget')}`]);
|
||||
});
|
||||
app.zones.forEach(zone=>{
|
||||
items.push([`zone|${zone.id}|control`,`${zone.name} · ${tr('history.controlTemperature')}`]);
|
||||
items.push([`zone|${zone.id}|gree`,`${zone.name} · ${tr('history.greeSensor')}`]);
|
||||
items.push([`zone|${zone.id}|external`,`${zone.name} · ${tr('history.roomSensor')}`]);
|
||||
items.push([`zone|${zone.id}|target`,`${zone.name} · ${tr('history.comfortTarget')}`]);
|
||||
items.push([`zone|${zone.id}|device_target`,`${zone.name} · ${tr('history.deviceSetpoint')}`]);
|
||||
items.push([`zone|${zone.id}|outdoor`,`${zone.name} · ${tr('history.outdoorTemperature')}`]);
|
||||
});
|
||||
[...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${entity}`]));
|
||||
return items;
|
||||
}
|
||||
|
||||
function customSeriesDefinition(key,index=0) {
|
||||
const [kind,id,field]=String(key).split('|');
|
||||
const color=historySeriesColor(index);
|
||||
if(kind==='device'){
|
||||
const device=app.devices.find(item=>item.id===id); if(!device) return null;
|
||||
const labels={indoor:tr('history.indoorTemperature'),outdoor:tr('history.greeOutdoor'),target:tr('history.deviceTarget')};
|
||||
const fields={indoor:'indoor_temperature',outdoor:'outdoor_temperature',target:'target_temperature'};
|
||||
return {key,label:`${device.name} · ${labels[field]||field}`,color,dash:field==='target'?[6,4]:[],value:row=>!row.zone_id&&!row.entity_id&&row.device_id===id?historyNumber(row[fields[field]]):NaN};
|
||||
}
|
||||
if(kind==='zone'){
|
||||
const zone=app.zones.find(item=>item.id===id); if(!zone) return null;
|
||||
const fields={control:['control_temperature',tr('history.controlTemperature')],gree:['gree_temperature',tr('history.greeSensor')],external:['external_temperature',tr('history.roomSensor')],target:['target_temperature',tr('history.comfortTarget')],device_target:['device_setpoint',tr('history.deviceSetpoint')],outdoor:['outdoor_temperature',tr('history.outdoorTemperature')]};
|
||||
const info=fields[field]; if(!info) return null;
|
||||
return {key,label:`${zone.name} · ${info[1]}`,color,dash:['target','device_target','outdoor'].includes(field)?[6,4]:[],value:row=>row.zone_id===id?historyNumber(row[info[0]]):NaN};
|
||||
}
|
||||
if(kind==='ha') return {key,label:`HA · ${id}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN};
|
||||
return null;
|
||||
}
|
||||
|
||||
function persistSavedCharts() {
|
||||
localStorage.setItem('gree_controller_saved_charts',JSON.stringify(app.savedCharts.slice(0,30)));
|
||||
}
|
||||
|
||||
function renderCustomBuilder() {
|
||||
const host=$('#historyCustomBuilder'); if(!host) return;
|
||||
if(app.historyTab!=='custom'){host.innerHTML='';host.classList.remove('active');return;}
|
||||
host.classList.add('active');
|
||||
const options=customSeriesOptions();
|
||||
const selected=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean);
|
||||
host.innerHTML=`<div class="panel custom-chart-panel"><div class="chart-title-row"><div><h3>${esc(tr('history.customTitle'))}</h3><p>${esc(tr('history.customDescription'))}</p></div></div>
|
||||
<div class="custom-chart-add"><select id="customSeriesSelect">${options.map(([value,label])=>`<option value="${esc(value)}">${esc(label)}</option>`).join('')}</select><button class="secondary" data-history-action="add-series">${esc(tr('history.addSeries'))}</button></div>
|
||||
<div class="custom-series-list">${selected.length?selected.map((item,index)=>`<span class="custom-series-chip"><i style="--chip-color:${esc(item.color)}"></i>${esc(item.label)}<button data-history-action="remove-series" data-index="${index}" aria-label="Remove">×</button></span>`).join(''):`<span class="field-note">${esc(tr('history.noCustomSeries'))}</span>`}</div>
|
||||
<div class="custom-chart-save"><input id="customChartName" placeholder="${esc(tr('history.chartName'))}"><button class="secondary" data-history-action="save-chart">${esc(tr('actions.save'))}</button><button class="primary" data-history-action="copy-chart-link">${esc(tr('history.copyLink'))}</button></div>
|
||||
<div class="saved-chart-list">${app.savedCharts.length?app.savedCharts.map(item=>`<div class="saved-chart-row"><button data-history-action="load-chart" data-id="${esc(item.id)}"><strong>${esc(item.name)}</strong><small>${esc(item.series.length)} ${esc(tr('history.series'))}</small></button><button class="danger" data-history-action="delete-chart" data-id="${esc(item.id)}">×</button></div>`).join(''):`<span class="field-note">${esc(tr('history.noSavedCharts'))}</span>`}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderCustomHistory() {
|
||||
renderCustomBuilder();
|
||||
const host=$('#historyCharts');
|
||||
host.innerHTML=historyChartMarkup('customHistoryChart',tr('history.customChart'),tr('history.customChartHint'));
|
||||
const series=app.customChartSeries.map((key,index)=>customSeriesDefinition(key,index)).filter(Boolean);
|
||||
const rows=[...app.historyData.devices,...app.historyData.zones,...app.historyData.sensors];
|
||||
drawLineChart($('#customHistoryChart'),series,rows,{height:390}); renderLegend($('#customHistoryChartLegend'),series);
|
||||
}
|
||||
|
||||
function renderHistoryPage() {
|
||||
renderHistorySummary();
|
||||
renderCustomBuilder();
|
||||
if(app.historyTab==='overview') renderOverviewHistory();
|
||||
else if(app.historyTab==='zones') renderZoneHistory();
|
||||
else if(app.historyTab==='devices') renderDeviceHistory();
|
||||
else if(app.historyTab==='sensors') renderSensorHistory();
|
||||
else renderCustomHistory();
|
||||
}
|
||||
|
||||
function drawCurrentChartIfVisible() {
|
||||
if (app.currentView === 'history') drawHistoryCharts(app.historyReadings || [], app.historyZone || 'all');
|
||||
if (app.currentView === 'history') renderHistoryPage();
|
||||
}
|
||||
|
||||
async function handleHistoryAction(button) {
|
||||
const action=button.dataset.historyAction;
|
||||
if(action==='add-series'){
|
||||
const value=$('#customSeriesSelect')?.value;
|
||||
if(value && !app.customChartSeries.includes(value)) app.customChartSeries.push(value);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
||||
}
|
||||
if(action==='remove-series'){
|
||||
app.customChartSeries.splice(Number(button.dataset.index),1);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath(), true); return;
|
||||
}
|
||||
if(action==='save-chart'){
|
||||
if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
const name=$('#customChartName')?.value.trim() || tr('history.customChart');
|
||||
const item={id:`chart-${Date.now()}`,name,series:[...app.customChartSeries],hours:$('#historyHours')?.value||'24'};
|
||||
app.savedCharts.unshift(item); persistSavedCharts(); renderCustomHistory(); toast(tr('history.chartSaved')); return;
|
||||
}
|
||||
if(action==='load-chart'){
|
||||
const item=app.savedCharts.find(entry=>entry.id===button.dataset.id); if(!item) return;
|
||||
app.customChartSeries=[...item.series]; if($('#historyHours')&&item.hours) $('#historyHours').value=String(item.hours);
|
||||
renderCustomHistory(); updateBrowserUrl(currentHistoryPath()); return;
|
||||
}
|
||||
if(action==='delete-chart'){
|
||||
app.savedCharts=app.savedCharts.filter(entry=>entry.id!==button.dataset.id); persistSavedCharts(); renderCustomHistory(); return;
|
||||
}
|
||||
if(action==='copy-chart-link'){
|
||||
if(!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
|
||||
const path=currentHistoryPath(); updateBrowserUrl(path, true); const link=`${location.origin}${path}`;
|
||||
try { await navigator.clipboard.writeText(link); } catch (_) { const area=document.createElement('textarea'); area.value=link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
|
||||
toast(tr('history.linkCopied')); return;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
@@ -711,6 +955,8 @@ document.addEventListener('click', async event => {
|
||||
if (button.dataset.go) { $('#moreDialog').close(); showView(button.dataset.go); return; }
|
||||
if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') updateZoneSensorFields(); if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); openDialog(button.dataset.open); return; }
|
||||
if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; }
|
||||
if (button.dataset.historyTab) { showHistoryTab(button.dataset.historyTab); return; }
|
||||
if (button.dataset.historyAction) { await handleHistoryAction(button); return; }
|
||||
if (button.dataset.scheduleTemplate) {
|
||||
const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true);
|
||||
if (!confirm(tr('schedules.replaceConfirm'))) return;
|
||||
@@ -740,7 +986,7 @@ document.addEventListener('click', async event => {
|
||||
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); toast(tr('house.presetUpdated')); }
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
}
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.effective_setpoint ?? zone.setpoint); return sendZoneControl(zone.id,{setpoint:clamp(base+Number(button.dataset.delta),8,30)}); } }
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
|
||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
||||
if (action === 'edit-zone') return populateZone(button.dataset.id);
|
||||
@@ -768,9 +1014,7 @@ $('#discoverButton').addEventListener('click', () => {
|
||||
openDialog('discoverDialog');
|
||||
});
|
||||
$('#historyRefresh').addEventListener('click', loadHistory);
|
||||
$('#historyZone').addEventListener('change', loadHistory);
|
||||
$('#historyHours').addEventListener('change', loadHistory);
|
||||
$('#historyHours').addEventListener('change', loadHistory);
|
||||
$('#historyHours').addEventListener('change', () => { updateBrowserUrl(currentHistoryPath(), true); loadHistory(); });
|
||||
$('#logsRefresh').addEventListener('click', loadLogs);
|
||||
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
||||
$('#themeSelect').addEventListener('change', event => setTheme(event.target.value));
|
||||
@@ -899,6 +1143,15 @@ $('#haTest').addEventListener('click', async () => {
|
||||
} catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
document.addEventListener('change', event => {
|
||||
const target=event.target;
|
||||
if(target.id==='historyZoneSelect'){app.historyZone=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
|
||||
else if(target.id==='historyDeviceSelect'){app.historyDevice=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
|
||||
else if(target.id==='historySensorSelect'){app.historySensor=target.value;updateBrowserUrl(currentHistoryPath());renderHistoryPage();}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', applyRouteFromLocation);
|
||||
|
||||
let historyResizeTimer;
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') { clearTimeout(historyResizeTimer); historyResizeTimer=setTimeout(drawCurrentChartIfVisible,120); } });
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); });
|
||||
@@ -911,6 +1164,8 @@ async function startApplication() {
|
||||
updateZoneSensorFields();
|
||||
updateSchedulePresetField();
|
||||
await loadBootstrap();
|
||||
applyRouteFromLocation();
|
||||
if (location.pathname === '/') updateBrowserUrl('/dashboard', true);
|
||||
}
|
||||
|
||||
startApplication().catch(error => console.error('Application startup failed:', error));
|
||||
|
||||
+14
-15
@@ -91,29 +91,28 @@
|
||||
|
||||
<section class="view" data-view="history">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="history.measurements">Measurements</span><h1 data-i18n="history.title">Climate history</h1></div></div>
|
||||
<div class="panel chart-panel">
|
||||
<div class="history-tabs" id="historyTabs" role="tablist">
|
||||
<button data-history-tab="overview" data-i18n="history.tabOverview">Overview</button>
|
||||
<button data-history-tab="zones" data-i18n="history.tabZones">Zones</button>
|
||||
<button data-history-tab="devices" data-i18n="history.tabDevices">GREE devices</button>
|
||||
<button data-history-tab="sensors" data-i18n="history.tabSensors">HA sensors</button>
|
||||
<button data-history-tab="custom" data-i18n="history.tabCustom">Custom chart</button>
|
||||
</div>
|
||||
<div class="panel chart-panel history-toolbar-panel">
|
||||
<div class="chart-toolbar">
|
||||
<select id="historyZone" aria-label="History zone"></select>
|
||||
<select id="historyHours">
|
||||
<div id="historyContextControls" class="history-context-controls"></div>
|
||||
<select id="historyHours" aria-label="History range">
|
||||
<option value="6" data-i18n="history.6h">6 hours</option>
|
||||
<option value="24" selected data-i18n="history.24h">24 hours</option>
|
||||
<option value="168" data-i18n="history.7d">7 days</option>
|
||||
<option value="720" data-i18n="history.30d">30 days</option>
|
||||
</select>
|
||||
<button class="secondary" id="historyRefresh" data-i18n="actions.show">Show</button>
|
||||
<button class="secondary" id="historyRefresh" data-i18n="actions.refresh">Refresh</button>
|
||||
</div>
|
||||
<div class="history-summary" id="historySummary"></div>
|
||||
</div>
|
||||
<div class="panel chart-panel history-chart-card">
|
||||
<div class="chart-title-row"><div><h3 data-i18n="history.temperatureOverview">Temperature overview</h3><p data-i18n="history.temperatureOverviewHint">GREE, room sensor, control temperature, targets and outdoor temperature.</p></div></div>
|
||||
<div class="chart-wrap"><canvas id="historyTemperatureChart" width="1000" height="420"></canvas></div>
|
||||
<div class="legend" id="historyTemperatureLegend"></div>
|
||||
</div>
|
||||
<div class="panel chart-panel history-chart-card">
|
||||
<div class="chart-title-row"><div><h3 data-i18n="history.operationOverview">Operation overview</h3><p data-i18n="history.operationOverviewHint">Demand, power and fan activity over the same period.</p></div></div>
|
||||
<div class="chart-wrap compact-chart"><canvas id="historyOperationChart" width="1000" height="300"></canvas></div>
|
||||
<div class="legend" id="historyOperationLegend"></div>
|
||||
</div>
|
||||
<div id="historyCustomBuilder" class="history-custom-builder"></div>
|
||||
<div id="historyCharts" class="history-charts"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="settings">
|
||||
@@ -290,7 +289,7 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<div id="toastStack" class="toast-stack" role="status" aria-live="polite" aria-atomic="false"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1 @@
|
||||
{
|
||||
"name": "GREE Controller",
|
||||
"short_name": "GREE",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#151515",
|
||||
"theme_color": "#151515",
|
||||
"icons": [{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]
|
||||
}
|
||||
{"name":"GREE Controller","short_name":"GREE","start_url":"/dashboard","display":"standalone","background_color":"#151515","theme_color":"#151515","icons":[{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]}
|
||||
|
||||
+52
-4
@@ -218,9 +218,6 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
.menu-list button { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); border-radius: 0; padding: 16px 9px; background: transparent; text-align: left; }
|
||||
.menu-list button:last-child { border-bottom: 0; }
|
||||
.auth-dialog { width: min(420px, calc(100% - 30px)); }
|
||||
#toast { position: fixed; z-index: 100; right: 18px; bottom: 92px; max-width: min(380px, calc(100% - 36px)); transform: translateY(15px); opacity: 0; pointer-events: none; padding: 12px 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-2); transition: .2s ease; font-size: 13px; }
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); }
|
||||
@media (min-width: 900px) {
|
||||
.bottom-nav { top: 92px; right: auto; bottom: auto; left: max(16px, calc((100vw - 1380px)/2)); grid-template-columns: 1fr; width: 90px; border: 1px solid var(--line); border-radius: 22px; padding: 9px; background: var(--surface); }
|
||||
.bottom-nav button { padding: 10px 4px; }
|
||||
@@ -347,7 +344,7 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
.automation-hint strong { display: block; margin-bottom: 5px; }
|
||||
.automation-hint p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
|
||||
/* Rich climate history (v0.4.2+) using the classic rounded UI. */
|
||||
/* Rich climate history (v0.4.4) using the classic rounded UI. */
|
||||
.history-chart-card { margin-top: 14px; }
|
||||
.chart-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.chart-title-row h3 { margin: 0 0 4px; font-size: 17px; }
|
||||
@@ -371,3 +368,54 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
.chart-toolbar { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.chart-toolbar button { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* History navigation, custom chart composer and linkable sub-pages. */
|
||||
.history-tabs { display: flex; gap: 7px; overflow-x: auto; margin: 0 0 14px; padding: 3px; scrollbar-width: none; }
|
||||
.history-tabs::-webkit-scrollbar { display: none; }
|
||||
.history-tabs button { flex: 0 0 auto; padding: 9px 13px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: var(--surface); font-size: 12px; font-weight: 700; }
|
||||
.history-tabs button.active { color: var(--accent-text); border-color: var(--accent); background: var(--accent); }
|
||||
.history-toolbar-panel { margin-bottom: 14px; }
|
||||
.history-context-controls { display: flex; flex: 1 1 260px; min-width: min(100%, 220px); }
|
||||
.history-context-controls label { width: 100%; }
|
||||
.history-context-hint { align-self: center; color: var(--muted); font-size: 12px; line-height: 1.4; }
|
||||
.history-charts { display: grid; gap: 14px; }
|
||||
.history-custom-builder { display: none; margin-bottom: 14px; }
|
||||
.history-custom-builder.active { display: block; }
|
||||
.custom-chart-panel { display: grid; gap: 13px; }
|
||||
.custom-chart-add, .custom-chart-save { display: flex; gap: 8px; align-items: center; }
|
||||
.custom-chart-add select, .custom-chart-save input { flex: 1; }
|
||||
.custom-series-list { display: flex; flex-wrap: wrap; gap: 7px; min-height: 34px; align-items: center; }
|
||||
.custom-series-chip { display: inline-flex; align-items: center; gap: 7px; max-width: 100%; padding: 7px 8px 7px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface-muted); color: var(--text-soft); font-size: 11px; }
|
||||
.custom-series-chip i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--chip-color, var(--accent)); }
|
||||
.custom-series-chip button { width: 23px; height: 23px; padding: 0; border-radius: 50%; color: var(--muted); background: transparent; font-size: 16px; line-height: 1; }
|
||||
.saved-chart-list { display: grid; gap: 7px; padding-top: 3px; border-top: 1px solid var(--line); }
|
||||
.saved-chart-row { display: grid; grid-template-columns: 1fr auto; gap: 7px; }
|
||||
.saved-chart-row > button:first-child { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--line); background: var(--surface-muted); text-align: left; }
|
||||
.saved-chart-row strong, .saved-chart-row small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.saved-chart-row small { color: var(--muted); }
|
||||
.saved-chart-row > .danger { width: 42px; padding: 0; border: 1px solid var(--line); background: var(--surface-muted); }
|
||||
|
||||
/* Toast stack: quiet success/error feedback that matches light and dark themes. */
|
||||
.toast-stack { position: fixed; z-index: 120; right: 18px; bottom: 92px; display: grid; gap: 9px; width: min(390px, calc(100vw - 36px)); pointer-events: none; }
|
||||
.toast-item { position: relative; display: grid; grid-template-columns: 34px 1fr 28px; gap: 10px; align-items: center; overflow: hidden; padding: 12px 11px; border: 1px solid var(--line); border-radius: 16px; background: color-mix(in srgb, var(--surface) 96%, transparent); backdrop-filter: blur(16px); opacity: 0; transform: translateY(10px) scale(.98); transition: opacity .2s ease, transform .2s ease; pointer-events: auto; }
|
||||
.toast-item.show { opacity: 1; transform: translateY(0) scale(1); }
|
||||
.toast-item.leaving { opacity: 0; transform: translateX(18px) scale(.98); }
|
||||
.toast-item.success { border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); }
|
||||
.toast-item.error { border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); }
|
||||
.toast-icon { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; color: var(--accent); background: var(--accent-soft); font-size: 15px; font-weight: 900; }
|
||||
.toast-item.error .toast-icon { color: var(--danger); background: var(--danger-soft); }
|
||||
.toast-copy { min-width: 0; }
|
||||
.toast-copy strong, .toast-copy span { display: block; }
|
||||
.toast-copy strong { margin-bottom: 2px; font-size: 12px; }
|
||||
.toast-copy span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.35; }
|
||||
.toast-close { width: 28px; height: 28px; padding: 0; border-radius: 50%; color: var(--muted); background: transparent; font-size: 19px; }
|
||||
.toast-progress { position: absolute; right: 0; bottom: 0; left: 0; height: 2px; background: var(--accent); transform-origin: left; animation: toast-progress 3.6s linear forwards; }
|
||||
.toast-item.error .toast-progress { background: var(--danger); animation-duration: 5.2s; }
|
||||
@keyframes toast-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.custom-chart-add, .custom-chart-save { align-items: stretch; flex-direction: column; }
|
||||
.toast-stack { right: 12px; bottom: 88px; width: calc(100vw - 24px); }
|
||||
}
|
||||
|
||||
.history-chart-card canvas { width: 100%; min-width: 720px; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v043';
|
||||
const CACHE = 'gree-controller-v044';
|
||||
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
|
||||
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
|
||||
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
||||
|
||||
Reference in New Issue
Block a user