diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 21a03eb..0288ef0 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,11 +1,19 @@ -# GREE Controller v0.5.2 - build and validation report +# GREE Controller v0.5.3 - build and validation report ## Scope -Version 0.5.2 includes the 0.5.0 dashboard/metrics/diagnostics release plus thermostat noise-control fixes. +Version 0.5.3 builds on 0.5.2 with UI consistency fixes, quiet-hours control, a flow-board simulator, event-log retention and Home Assistant sensor aliases. ## Implemented changes +- Replaced the textual Connected/Disconnected label with a green/amber/red status dot in the top-left brand header. +- Fixed History filter layout so Zone/Device/Sensor and time-range controls keep equal heights and do not stretch after changing tabs. +- Reworked the Simulator into a Node-RED-style dotted flow board with connected live nodes for sensor input, thermostat target, decision, unit command and next event. +- Added configurable night mode (default 22:00-06:00): thermostat fan speed is capped during quiet hours and compatible units can be asked to keep Quiet enabled. +- Device cards now use **Name / protocol** instead of Rename and no longer show the fixed UDP port next to the IP address. +- Settings are split into visual blocks; the self-signed HA example uses a generic private address instead of an installation-specific address. +- Event/debug log retention is configurable and old rows are pruned automatically; the Events page can change retention and prune immediately. +- Home Assistant temperature sensors can have UI aliases; aliases replace raw entity IDs in history selectors, legends and custom-chart labels without changing stored entity IDs. - Added a dedicated Web UI simulator page that visualizes thermostat/automation decisions as graphical blocks with hours, targets, demand, standby behavior, rule actions and upcoming events. - Quick Thermostat cards on the dashboard can enable/disable a zone directly; the edit dialog is no longer required for this action. - SQLite history housekeeping is tiered to chart resolution: full recent data, 10-minute buckets after 24 hours and 30-minute buckets after 7 days. @@ -29,7 +37,7 @@ Version 0.5.2 includes the 0.5.0 dashboard/metrics/diagnostics release plus ther - Shell syntax validation for all `scripts/*.sh` files. - JSON validation for EN/PL language packs, PWA manifest, Home Assistant manifest and HA translations. - EN/PL UI translation-key parity and literal UI translation-reference validation. -- SQLite schema execution plus tiered compaction tests. +- SQLite schema execution plus tiered compaction and event-log retention tests. - SQLite archive-selection query tests for device, zone and Home Assistant histories. - Configuration-clear behavior verified to preserve metric history, event logs and API tokens. - Version consistency checked for Cargo package, Cargo lock root package, README and Home Assistant manifest. diff --git a/Cargo.lock b/Cargo.lock index 8f57a1c..36c9f39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.5.2" +version = "0.5.3" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index df9b5e5..052b3bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.5.2" +version = "0.5.3" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index cbcb835..3eabeff 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ -4989a37261ebad2b981c62ee9b5be41cc298f982e101782f27151774293f7a9b ./.env.example +520d003bd881ccfd193bd59ed1264466903070b688bef23f9477aa7728207e7a ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -60e043828c8540fd46114141a01838f82e09aabe460ff1d6e7886a57d7c0cd33 ./BUILD_REPORT.md -161b2168c551e4e4563161ba2b1aa417a57332c984e3cf3f729fc5d6df987921 ./Cargo.lock -043fe7cc49e07a875d87ab8dfa6eef2bca73b6317038010c18765ea9950992cd ./Cargo.toml +148609fdc0b1465895f6c5258258ccaa240cf7feb52ac1dbd7bca35e635890be ./BUILD_REPORT.md +eaf6bdc44eb92f2372a8da62fa60e88cfec3b5e07bc295866ee20899ecb3f436 ./Cargo.lock +a6cbb8074f6d065adcce27198001008c6fa30b8cd0d124de38637e2eedf06927 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -975335835c4eae1f7a90ac903be39e4a1fe5c318c0d80ba71bfad225b2ed8488 ./README.md +fb59b28f19e10c01349ba7e271072b9c6950e5596c97cc93ed4c0214620eaa6a ./README.md a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs -6577e178c03cdb5f9e5b55eba4c25e2733bd5ca48daa76cf7910cda776066517 ./docs/API.md +1ed94830fa34a957949ebb8d447c59b437f0460ac58710e10a14d26d0d6f1ca0 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md @@ -19,7 +19,7 @@ fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assista 071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py 8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py -6d965c12b1dcfcc8beb439d2035fcb4e74e58db919682ce77b771729ea41e4a0 ./home-assistant/custom_components/gree_controller/manifest.json +48e66790617ee5ce8299bb388cc603a6bc8e6b6b29d2b1d05ef69d3b15e55079 ./home-assistant/custom_components/gree_controller/manifest.json d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py 5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py 59632b92ed799234c632324b00e9a48e89ee06a5c49b8c27d29e495ee42e40e9 ./home-assistant/custom_components/gree_controller/switch.py @@ -27,8 +27,8 @@ d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -ea76d53c79adef54513e3d72c342c8b7c01e8a9ac13c56a19703c6f3642a6630 ./lang/en.json -14192dd2d83f8ccc8a4d36d54f76d8e941397b6d9b05e55edeb07083ec2426a3 ./lang/pl.json +40a383bb95be5d2de716262002dc4e2dc738730484919e0164c678098e745866 ./lang/en.json +299a4e8404b6e14d1c545790d04e8619237f5ceecbcceafe646427897e8070c5 ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh @@ -41,24 +41,24 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -1e82b152e3e8badbceb2ffa6424fea027b7aad3ad87e8b1b46085caa66760ba8 ./src/api.rs -4bbbe739219730d7369965277e689b5a557202e2674869705009ca4c3239ad38 ./src/config.rs -1ec49d30ff14fc341bb15177ff195db41fb927d67114d1c1cb8ca671c7ea73ec ./src/db.rs -d6088e1b36997a5bc40618501bcba87b69ae712ae841310d6c65d286b13a7c12 ./src/engine.rs +df42f5610d74bcd6c43766c26ae8e23197a37483dba69c044fe85e44a06e4ef4 ./src/api.rs +94e05b5bdc7c0eeb4bec823f83ae36246836c18f2b164ebf1629a82723840eb5 ./src/config.rs +a89bc49e359715109149130a10ac60abd26bfd2bc18c5ee5142e01696d9f9cdd ./src/db.rs +9ba09db2faef9f03e7d3cd57a618dedf5a8caebb81ece8ee683521d5a2d4468d ./src/engine.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs 584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs 1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs -eae77ed4aed700c72a0c1e6d672ffb0dd0fd6f50d0e7f6e1f8b8ab41cbf5df42 ./src/models.rs +e4e823cf1b3c26de297ba60d1607f1f4ff572f489b353b226b569131c9bb915a ./src/models.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs b76543136fb9c9742657a9b047629b7382d0e5d73ab468609c2be643e0681d25 ./src/protocol/gree.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs -b6061a3325228fc5f70adb819d1b443e560d36964f98b8edb7c5bd079241348e ./src/queries.rs +d1cba5e55c77d44c75a9c9897ae76c327fd96bdf9c01ad27e34322b689348487 ./src/queries.rs e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -d22d55f28e01cba9f25296ce601628dcbb62d202ee233a74304c3cdaec720192 ./web/app.js +8577a2cf72a5751cad285a29c824b182d29b4793d4171ed94916a6e829c60b43 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -1180d4044630ef04ce90da4c79104bcb02848c0156aba378ad21fbd51efc006e ./web/index.html +4720c9abd96159ae9a6757ede9a6c5e0a2ccb4d7e37f8c2a8c16e814d3f0818a ./web/index.html c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest -b54efeab2aa577b92df7842c9f2460a2a3ad054d83a4b4b0635ed6a21cd47cca ./web/styles.css -98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js +ff9b02c818474b15fbcd67105117b4ab7b21a29bca28141c9f162296324d3f70 ./web/styles.css +e1cddac26b3a2ac6142c90661a3085d88079330aa2bacd6b23ff5164620efcdf ./web/sw.js diff --git a/README.md b/README.md index 09aa8a0..7801683 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.5.2**. +Current version: **0.5.3**. ## Highlights @@ -26,6 +26,9 @@ Current version: **0.5.2**. - REST API and WebSocket updates, - responsive PWA optimized for phones, - dashboard automation-plan blocks showing current zone decisions and upcoming schedule changes, +- Node-RED-style live automation flow board with sensor → thermostat → decision → unit command nodes, +- configurable night mode with quiet hours, maximum fan speed and optional GREE Quiet requests, +- configurable event-log retention and Home Assistant sensor aliases used by history charts/selectors, - settings import/export and an optional live debug overlay with API logs and decrypted GREE frame payloads, - JSON-based UI localization loaded from embedded `lang/*.json` language packs, - light, dark and system appearance modes stored in a browser cookie, @@ -42,7 +45,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.5.2.zip +unzip gree-controller-v0.5.3.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh diff --git a/docs/API.md b/docs/API.md index 79d4cd2..0915ac5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -227,3 +227,14 @@ When InfluxDB is enabled, samples are written in parallel. Maintenance also back ## InfluxDB long-term storage Runtime settings support either InfluxDB 1.x (`version=1`, URL, database and optional username/password) or InfluxDB 2.x (`version=2`, URL, org, bucket and token). The same values can be supplied with `GREE_CONTROLLER_INFLUX_*` environment variables; see `.env.example`. An explicitly configured environment value overrides the persisted setting at startup. + + +## Event log retention + +`GET /api/events/retention` returns the current event/debug log retention window. `PUT /api/events/retention` accepts `{"days":30}` (1-3650), persists it and immediately prunes older event rows. The same value is part of runtime settings as `event_log_retention_days` and can be set at startup with `GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS`. + +## Night mode and sensor aliases + +Runtime settings include `night_mode` with `enabled`, `start_time`, `end_time`, `max_fan_speed` (1-5) and `force_quiet`. Overnight windows crossing midnight are supported. During the window thermostat-generated fan commands are limited to the configured maximum; Auto is converted to a bounded low speed, and `Quiet` is requested only when the unit supports it. + +`home_assistant.sensor_aliases` is a map of Home Assistant `entity_id` to a friendly UI name, for example `{"sensor.gabinet_temperature":"Gabinet"}`. Aliases affect labels in the controller UI/history only; metric storage and Home Assistant API requests continue to use the original entity ID. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index f1a383f..bee9b58 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.5.2", + "version": "0.5.3", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 6e8df41..11ee2f2 100644 --- a/lang/en.json +++ b/lang/en.json @@ -228,7 +228,7 @@ "toast.tokenCopied": "Token copied", "toast.tokenRevoked": "Token revoked", "devices.protocolAuto": "Auto (V1 + V2)", - "devices.rename": "Rename", + "devices.rename": "Name / protocol", "devices.protocolChangeHint": "Changing protocol clears the saved device key and performs a new bind on the next request.", "discovery.title": "Discover GREE devices", "discovery.help": "Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.", @@ -278,7 +278,7 @@ "settings.outdoorEntity": "Outdoor temperature entity_id", "settings.outdoorAssist": "Use outdoor temperature as smart-control assist", "settings.allowInvalidTls": "Allow invalid/self-signed HTTPS certificate", - "settings.allowInvalidTlsHint": "Use only for a trusted local Home Assistant server, for example https://10.87.65.2.", + "settings.allowInvalidTlsHint": "Use only for a trusted local Home Assistant server, for example https://192.168.50.25.", "zones.sleepNow": "Sleep now", "house.autoAll": "Auto schedule", "house.comfortAll": "Comfort all", @@ -404,7 +404,7 @@ "nav.simulation": "Simulator", "simulation.eyebrow": "Simulator", "simulation.title": "Automation simulator", - "simulation.description": "Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.", + "simulation.description": "Live preview of the thermostat and automation logic. The flow board shows how sensor readings, schedules, night mode and thermostat decisions become commands sent to each GREE unit.", "simulation.timelineEyebrow": "Timeline", "simulation.timelineTitle": "What will happen next", "simulation.zoneEyebrow": "Zones", @@ -443,6 +443,45 @@ "simulation.reasonSatisfied": "Target is reached. The thermostat moves the unit into standby around {standby}°C to keep the room quiet and stable.", "simulation.activeTargetLabel": "Active target before device rounding: {value}°C", "simulation.standbyTargetLabel": "Standby target before device rounding: {value}°C", - "common.fan": "Fan" + "common.fan": "Fan", + "devices.nameProtocol": "Name / protocol", + "history.range": "Range", + "settings.controllerHint": "Runtime and LAN discovery settings.", + "settings.nightMode": "Night mode", + "settings.nightModeHint": "During selected hours the thermostat limits fan speed and can request Quiet on supported units.", + "settings.nightModeEnabled": "Enable night mode", + "settings.nightStart": "Start", + "settings.nightEnd": "End", + "settings.nightMaxFan": "Maximum fan speed", + "settings.nightForceQuiet": "Use Quiet when supported", + "settings.metricsAndLogs": "Metrics and logs", + "settings.eventRetentionDays": "Event/log retention (days)", + "settings.debugHint": "Live diagnostics displayed on every application page.", + "settings.sensorAliases": "Sensor aliases", + "settings.sensorAliasesHint": "Friendly names are used in charts and selectors; the original entity_id remains unchanged.", + "settings.aliasPlaceholder": "Friendly name", + "settings.noSensorAliases": "No Home Assistant sensors are known yet. Add an entity_id below.", + "settings.aliasRequired": "Enter both entity_id and alias.", + "settings.saveHint": "Save changes made in the blocks above.", + "logs.retention": "Log retention", + "logs.retentionHint": "Old event rows are removed automatically during maintenance.", + "logs.keepFor": "Keep for", + "logs.retentionSaved": "Log retention set to {days} days.", + "simulation.flowEyebrow": "Live flow", + "simulation.flowTitle": "Automation flow board", + "simulation.legendInput": "Input", + "simulation.legendLogic": "Logic", + "simulation.legendAction": "Action", + "simulation.fanMax": "fan max", + "simulation.globalInput": "Global input", + "simulation.thermostat": "Thermostat", + "simulation.callForComfort": "Comfort demand", + "simulation.standby": "Quiet standby", + "simulation.nextEvent": "Next event", + "simulation.noEventShort": "No event", + "simulation.schedule": "Schedule", + "common.days": "days", + "actions.clear": "Clear", + "simulation.scheduled": "Scheduled" } } diff --git a/lang/pl.json b/lang/pl.json index 1b0e695..a77c84d 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -228,7 +228,7 @@ "toast.tokenCopied": "Token skopiowany", "toast.tokenRevoked": "Token unieważniony", "devices.protocolAuto": "Auto (V1 + V2)", - "devices.rename": "Zmień nazwę", + "devices.rename": "Nazwa / protokół", "devices.protocolChangeHint": "Zmiana protokołu usuwa zapisany klucz urządzenia i wykona ponowny bind przy następnym żądaniu.", "discovery.title": "Wykrywanie urządzeń GREE", "discovery.help": "Auto wyszukuje urządzenia AES-ECB i AES-GCM. Kilka przebiegów zwiększa skuteczność, gdy wiele modułów Wi-Fi odpowiada na ten sam broadcast.", @@ -278,7 +278,7 @@ "settings.outdoorEntity": "entity_id temperatury zewnętrznej", "settings.outdoorAssist": "Używaj temperatury zewnętrznej jako wsparcia inteligentnego sterowania", "settings.allowInvalidTls": "Zezwól na nieważny/samopodpisany certyfikat HTTPS", - "settings.allowInvalidTlsHint": "Używaj tylko dla zaufanego lokalnego Home Assistanta, np. https://10.87.65.2.", + "settings.allowInvalidTlsHint": "Używaj tylko dla zaufanego lokalnego serwera Home Assistant, np. https://192.168.50.25.", "zones.sleepNow": "Sen teraz", "house.autoAll": "Auto harmonogram", "house.comfortAll": "Komfort wszędzie", @@ -404,7 +404,7 @@ "nav.simulation": "Symulator", "simulation.eyebrow": "Symulator", "simulation.title": "Symulator automatyzacji", - "simulation.description": "Podgląd pokazuje, jak logika termostatu i automatyzacji interpretuje teraz każdą strefę: temperatury, cele, zapotrzebowanie, tryb podtrzymania i kolejne zdarzenia.", + "simulation.description": "Podgląd na żywo logiki termostatu i automatyzacji. Pole przepływu pokazuje, jak pomiary, harmonogramy, tryb nocny i decyzje termostatu zamieniają się w komendy wysyłane do jednostek GREE.", "simulation.timelineEyebrow": "Oś czasu", "simulation.timelineTitle": "Co wydarzy się dalej", "simulation.zoneEyebrow": "Strefy", @@ -443,6 +443,45 @@ "simulation.reasonSatisfied": "Cel został osiągnięty. Termostat przełącza jednostkę w podtrzymanie około {standby}°C, aby utrzymać ciszę i stabilność.", "simulation.activeTargetLabel": "Aktywny cel przed zaokrągleniem: {value}°C", "simulation.standbyTargetLabel": "Cel podtrzymania przed zaokrągleniem: {value}°C", - "common.fan": "Wentylator" + "common.fan": "Wentylator", + "devices.nameProtocol": "Nazwa / protokół", + "history.range": "Zakres", + "settings.controllerHint": "Ustawienia pracy kontrolera i wykrywania urządzeń w LAN.", + "settings.nightMode": "Tryb nocny", + "settings.nightModeHint": "W wybranych godzinach termostat ogranicza obroty wentylatora i może włączać Quiet w obsługiwanych jednostkach.", + "settings.nightModeEnabled": "Włącz tryb nocny", + "settings.nightStart": "Początek", + "settings.nightEnd": "Koniec", + "settings.nightMaxFan": "Maksymalny bieg wentylatora", + "settings.nightForceQuiet": "Używaj Quiet, jeśli jednostka obsługuje", + "settings.metricsAndLogs": "Metryki i logi", + "settings.eventRetentionDays": "Retencja zdarzeń/logów (dni)", + "settings.debugHint": "Diagnostyka na żywo wyświetlana na każdej podstronie aplikacji.", + "settings.sensorAliases": "Aliasy czujników", + "settings.sensorAliasesHint": "Przyjazne nazwy są używane na wykresach i w wyborach; oryginalny entity_id pozostaje bez zmian.", + "settings.aliasPlaceholder": "Przyjazna nazwa", + "settings.noSensorAliases": "Nie wykryto jeszcze czujników Home Assistant. Możesz dodać entity_id poniżej.", + "settings.aliasRequired": "Podaj entity_id oraz alias.", + "settings.saveHint": "Zapisz zmiany wykonane w blokach powyżej.", + "logs.retention": "Retencja logów", + "logs.retentionHint": "Stare wpisy zdarzeń są automatycznie usuwane podczas konserwacji.", + "logs.keepFor": "Przechowuj przez", + "logs.retentionSaved": "Retencja logów ustawiona na {days} dni.", + "simulation.flowEyebrow": "Przepływ na żywo", + "simulation.flowTitle": "Schemat pracy automatyzacji", + "simulation.legendInput": "Wejście", + "simulation.legendLogic": "Logika", + "simulation.legendAction": "Akcja", + "simulation.fanMax": "maks. wentylator", + "simulation.globalInput": "Wejście globalne", + "simulation.thermostat": "Termostat", + "simulation.callForComfort": "Zapotrzebowanie", + "simulation.standby": "Ciche podtrzymanie", + "simulation.nextEvent": "Następne zdarzenie", + "simulation.noEventShort": "Brak zdarzenia", + "simulation.schedule": "Harmonogram", + "common.days": "dni", + "actions.clear": "Wyczyść", + "simulation.scheduled": "Zaplanowany" } } diff --git a/src/api.rs b/src/api.rs index 37c1d7c..e978700 100644 --- a/src/api.rs +++ b/src/api.rs @@ -9,7 +9,7 @@ use axum::{ Json, Router, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use chrono::{Duration as ChronoDuration, Utc}; +use chrono::{Duration as ChronoDuration, NaiveTime, Utc}; use futures_util::StreamExt; use rand::{rngs::OsRng, RngCore}; use serde::Deserialize; @@ -59,6 +59,7 @@ pub fn router(state: AppState) -> Router { .route("/api/history", get(history)) .route("/api/control-plan", get(control_plan)) .route("/api/events", get(events)) + .route("/api/events/retention", get(get_event_retention).put(update_event_retention)) .route("/api/settings", get(get_settings).put(update_settings)) .route("/api/settings/export", get(export_settings)) .route("/api/settings/import", post(import_settings)) @@ -1060,6 +1061,27 @@ async fn events(State(state): State, Query(query): Query) Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}))) } +#[derive(Debug, Deserialize)] +struct EventRetentionInput { days: u32 } + +async fn get_event_retention(State(state): State) -> Json { + let days = state.settings.read().await.event_log_retention_days; + Json(json!({"days": days})) +} + +async fn update_event_retention(State(state): State, Json(input): Json) -> Result, AppError> { + let mut settings = state.settings.write().await; + settings.event_log_retention_days = input.days.clamp(1, 3650); + state.db.save_runtime_settings(&settings)?; + let days = settings.event_log_retention_days; + drop(settings); + let removed = state.db.prune_events(days as i64)?; + state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed})); + let public = { let settings = state.settings.read().await; public_settings(&*settings) }; + state.broadcast("settings.updated", public); + Ok(Json(json!({"days": days, "removed": removed}))) +} + async fn get_settings(State(state): State) -> Json { let settings = state.settings.read().await; Json(public_settings(&*settings)) @@ -1080,6 +1102,9 @@ async fn update_settings(State(state): State, Json(mut input): Json, Json(mut input): Json(), alias.chars().take(80).collect::())) + }) + .collect(); +} + +fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> { + NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M") + .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; + NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M") + .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; + settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5); + Ok(()) +} + async fn export_settings(State(state): State) -> Result, AppError> { let settings = state.settings.read().await.clone(); Ok(Json(state.db.export_configuration(settings)?)) @@ -1124,6 +1170,9 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App async fn import_settings(State(state): State, Json(mut export): Json) -> Result, AppError> { validate_configuration_export(&export)?; export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650); + export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650); + normalize_sensor_aliases(&mut export.settings); + validate_night_mode(&mut export.settings)?; export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650); state.db.replace_configuration(&export)?; state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); @@ -1216,8 +1265,10 @@ fn public_settings(settings: &RuntimeSettings) -> Value { "outdoor_assist_enabled": settings.outdoor_assist_enabled, "history_retention_days": settings.history_retention_days, "history_compaction_enabled": settings.history_compaction_enabled, + "event_log_retention_days": settings.event_log_retention_days, "suppress_device_beep": settings.suppress_device_beep, "debug": settings.debug, + "night_mode": settings.night_mode, "influxdb": { "enabled": settings.influxdb.enabled, "version": settings.influxdb.version, @@ -1239,6 +1290,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value { "default_entity_id": settings.home_assistant.default_entity_id, "outdoor_entity_id": settings.home_assistant.outdoor_entity_id, "allow_invalid_tls": settings.home_assistant.allow_invalid_tls, + "sensor_aliases": settings.home_assistant.sensor_aliases, } }) } diff --git a/src/config.rs b/src/config.rs index 3ba1d39..453b6e7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use std::{env, net::SocketAddr, path::PathBuf}; use anyhow::{Context, Result}; use clap::Parser; -use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, RuntimeSettings}; +use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, RuntimeSettings}; #[derive(Debug, Clone, Parser)] #[command(author, version, about)] @@ -54,12 +54,20 @@ impl Config { outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true), history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true), + event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false), influxdb: influx_settings_from_env(), debug: DebugSettings { overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false), gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false), }, + night_mode: NightModeSettings { + enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false), + start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()), + end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()), + max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5), + force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true), + }, home_assistant: HomeAssistantSettings { url: env::var("HA_URL").unwrap_or_default(), token: env::var("HA_TOKEN").unwrap_or_default(), @@ -68,6 +76,7 @@ impl Config { allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS") .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) .unwrap_or(false), + sensor_aliases: Default::default(), }, } } @@ -78,9 +87,17 @@ impl Config { settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650); } if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; } + if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() { + settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650); + } if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; } if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; } if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; } + if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; } + if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { if !value.trim().is_empty() { settings.night_mode.start_time = value; } } + if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } } + if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); } + if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; } let influx_env_present = [ "GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL", @@ -114,6 +131,7 @@ fn env_bool(name: &str) -> Option { } fn env_u32(name: &str) -> Option { env::var(name).ok()?.parse().ok() } +fn env_u8(name: &str) -> Option { env::var(name).ok()?.parse().ok() } fn first_env(names: &[&str]) -> Option { names.iter().find_map(|name| { diff --git a/src/db.rs b/src/db.rs index 6bb04bc..4e08e84 100644 --- a/src/db.rs +++ b/src/db.rs @@ -469,6 +469,12 @@ impl Db { rows.collect::, _>>().map_err(Into::into) } + pub fn prune_events(&self, retention_days: i64) -> Result { + let before = Utc::now() - Duration::days(retention_days.max(1)); + let conn = self.lock()?; + Ok(conn.execute(queries::PRUNE_EVENTS, [before.to_rfc3339()])? as u64) + } + pub fn list_api_tokens(&self) -> Result> { let conn = self.lock()?; let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?; @@ -601,6 +607,12 @@ mod tests { assert_eq!(db.list_devices().unwrap().len(), 1); db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap(); assert_eq!(db.list_events(10).unwrap().len(), 1); + { + let conn = db.lock().unwrap(); + conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap(); + } + assert_eq!(db.prune_events(30).unwrap(), 1); + assert_eq!(db.list_events(10).unwrap().len(), 1); let access_token = ApiTokenInfo { id: "token-1".into(), diff --git a/src/engine.rs b/src/engine.rs index 3768f18..83a7bf0 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -7,7 +7,7 @@ use crate::{ error::AppError, home_assistant, influxdb, - models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading}, + models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading}, state::AppState, }; @@ -68,6 +68,12 @@ pub fn start(state: AppState) { Err(err) => tracing::warn!(error=?err, "cannot prune readings"), } } + let event_retention_days = settings.event_log_retention_days.max(1) as i64; + match maintenance_state.db.prune_events(event_retention_days) { + Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"), + Ok(_) => {} + Err(err) => tracing::warn!(error=?err, "cannot prune event log"), + } sleep(Duration::from_secs(6 * 60 * 60)).await; } }); @@ -338,6 +344,7 @@ async fn control_zones(state: &AppState) -> Result<()> { } } let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; + let night_active = night_mode_active(&settings.night_mode, Local::now().time()); for mut zone in state.db.list_zones()? { if !zone.enabled { continue; } @@ -459,21 +466,36 @@ async fn control_zones(state: &AppState) -> Result<()> { let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target }); zone.device_setpoint = Some(desired_device_target); - let desired_fan = if zone.smart_fan { + let desired_fan = if night_active { + let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5); + if zone.smart_fan { + Some(night_limited_fan_speed( + smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand), + max_fan, + )) + } else if device.fan_speed == 0 || device.fan_speed > max_fan { + Some(max_fan) + } else { + Some(device.fan_speed) + } + } else if zone.smart_fan { Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand)) } else { None }; // When the room becomes satisfied, ask compatible units for Quiet in the same // frame as the standby setpoint and Low fan. When demand returns, disable Quiet - // only on that transition so a user's manual Quiet choice is not constantly - // overwritten while the zone is actively heating/cooling. + // on the normal smart-fan transition. When scheduled night mode owns Quiet, it + // explicitly enables it inside the window and releases it outside the window. let desired_quiet = smart_quiet_command( zone.smart_fan, state.gree.quiet_command_supported(&device.id), previous_demand, zone.demand, device.quiet, + settings.night_mode.enabled, + night_active, + settings.night_mode.force_quiet, ); let needs_command = !device.power @@ -505,6 +527,7 @@ async fn control_zones(state: &AppState) -> Result<()> { "outdoor_temperature": outdoor_temperature, "fan_speed": updated_device.fan_speed, "quiet": updated_device.quiet, + "night_mode": night_active, })); } Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), @@ -669,13 +692,34 @@ fn smart_quiet_command( previous_demand: bool, demand: bool, device_quiet: bool, + night_enabled: bool, + night_active: bool, + night_force_quiet: bool, ) -> Option { - if !smart_fan || !quiet_supported { return None; } + if !quiet_supported { return None; } + if night_enabled && night_force_quiet { + if night_active { return Some(true); } + if device_quiet { return Some(false); } + } + if !smart_fan { return None; } if !demand { return Some(true); } if !previous_demand && device_quiet { return Some(false); } None } +fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 { + let max_fan = max_fan.clamp(1, 5); + if requested == 0 { 1 } else { requested.min(max_fan) } +} + +pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool { + if !settings.enabled { return false; } + let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; }; + let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; }; + if start == end { return true; } + if start < end { time >= start && time < end } else { time >= start || time < end } +} + fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option, demand: bool) -> u8 { // When the thermostat is satisfied, keep airflow quiet instead of leaving the // unit in Auto. The caller sends this together with the standby setpoint in @@ -777,8 +821,9 @@ pub async fn build_control_plan(state: &AppState) -> Result Result Result, limit: usize) -> Vec { + if !settings.enabled || limit == 0 { return Vec::new(); } + let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); }; + let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); }; + let mut events = Vec::new(); + for minute in 1..=(48 * 60) { + let candidate = now + chrono::Duration::minutes(minute); + let time = candidate.time(); + let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() { + let quiet = if settings.force_quiet { " + Quiet" } else { "" }; + ("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet)) + } else if time.hour() == end.hour() && time.minute() == end.minute() { + ("night_mode_end", "Night mode ends".to_string()) + } else { + continue; + }; + events.push(ControlPlanEvent { + at: candidate.with_timezone(&Utc), + kind: kind.into(), + label, + preset: None, + target_temperature: None, + }); + if events.len() >= limit { break; } + } + events +} + fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime) -> Option { let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; for minute in 1..=(24 * 60) { @@ -1070,11 +1147,23 @@ mod tests { #[test] fn smart_quiet_follows_satisfied_transition_only_when_supported() { - assert_eq!(smart_quiet_command(true, true, true, false, false), Some(true)); - assert_eq!(smart_quiet_command(true, true, false, true, true), Some(false)); - assert_eq!(smart_quiet_command(true, true, true, true, true), None); - assert_eq!(smart_quiet_command(true, false, true, false, false), None); - assert_eq!(smart_quiet_command(false, true, true, false, false), None); + assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true)); + assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false)); + assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None); + assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None); + assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None); + } + + #[test] + fn night_mode_handles_midnight_and_limits_auto_fan() { + let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true }; + assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap())); + assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap())); + assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap())); + assert_eq!(night_limited_fan_speed(0, 1), 1); + assert_eq!(night_limited_fan_speed(3, 1), 1); + assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true)); + assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false)); } #[test] diff --git a/src/models.rs b/src/models.rs index c15b36a..77494df 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -27,9 +28,13 @@ fn default_heat_comfort() -> f64 { 21.0 } fn default_heat_sleep() -> f64 { 19.0 } fn default_heat_away() -> f64 { 17.0 } fn default_history_retention_days() -> u32 { 30 } +fn default_event_log_retention_days() -> u32 { 30 } fn default_influx_version() -> String { "2".into() } fn default_influx_database() -> String { "gree_controller".into() } fn default_influx_threshold_days() -> u32 { 30 } +fn default_night_start() -> String { "22:00".into() } +fn default_night_end() -> String { "06:00".into() } +fn default_night_max_fan_speed() -> u8 { 1 } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { @@ -409,6 +414,9 @@ pub struct HomeAssistantSettings { /// Accept self-signed/expired certificates for local Home Assistant HTTPS. #[serde(default)] pub allow_invalid_tls: bool, + /// Friendly labels used only by the controller UI/charts; entity_id remains the storage key. + #[serde(default)] + pub sensor_aliases: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -465,6 +473,34 @@ pub struct DebugSettings { pub gree_frames: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NightModeSettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_night_start")] + pub start_time: String, + #[serde(default = "default_night_end")] + pub end_time: String, + /// Maximum fan speed used by thermostat control during night hours (1=Low..5=High). + #[serde(default = "default_night_max_fan_speed")] + pub max_fan_speed: u8, + /// Ask compatible GREE units to keep Quiet enabled during the whole night window. + #[serde(default = "default_true")] + pub force_quiet: bool, +} + +impl Default for NightModeSettings { + fn default() -> Self { + Self { + enabled: false, + start_time: default_night_start(), + end_time: default_night_end(), + max_fan_speed: default_night_max_fan_speed(), + force_quiet: true, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigurationExport { pub format_version: u32, @@ -528,6 +564,10 @@ pub struct ControlPlan { pub house_mode: String, pub outdoor_temperature: Option, pub control_strategy: String, + pub night_mode_active: bool, + pub night_mode_start: String, + pub night_mode_end: String, + pub night_mode_max_fan_speed: u8, pub next_events: Vec, pub zones: Vec, pub rules: Vec, @@ -554,6 +594,9 @@ pub struct RuntimeSettings { pub history_retention_days: u32, #[serde(default = "default_true")] pub history_compaction_enabled: bool, + /// Retention window for controller event/debug log rows. + #[serde(default = "default_event_log_retention_days")] + pub event_log_retention_days: u32, /// Add protocol-specific buzzer suppression fields to command frames. #[serde(default)] pub suppress_device_beep: bool, @@ -561,6 +604,8 @@ pub struct RuntimeSettings { pub influxdb: InfluxDbSettings, #[serde(default)] pub debug: DebugSettings, + #[serde(default)] + pub night_mode: NightModeSettings, pub home_assistant: HomeAssistantSettings, } diff --git a/src/queries.rs b/src/queries.rs index 1dc410b..a071820 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -382,6 +382,7 @@ 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 = "SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1"; +pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1"; pub const LIST_API_TOKENS: &str = "SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC"; diff --git a/web/app.js b/web/app.js index f0ddcd8..238a002 100644 --- a/web/app.js +++ b/web/app.js @@ -20,7 +20,7 @@ const app = { historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, customChartSeries: [], savedCharts: [], zoneControlSeq: {}, zoneTemperatureTimers: {}, - controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, + controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, sensorAliases: {}, }; try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; } @@ -38,6 +38,17 @@ const historyNumber = value => value === null || value === undefined || value == const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`); const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value); const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—'; +const haSensorLabel = entity => app.sensorAliases?.[entity] || app.settings?.home_assistant?.sensor_aliases?.[entity] || entity; + +function updateConnectionIndicator(status) { + app.connectionStatus = status || 'connecting'; + const node = $('#connectionLabel'); + if (!node) return; + const label = tr(`status.${app.connectionStatus}`); + node.className = `connection-dot ${app.connectionStatus}`; + node.setAttribute('aria-label', label); + node.title = label; +} function applyTheme() { const resolved = app.theme === 'light' || app.theme === 'dark' @@ -61,8 +72,7 @@ function applyTranslations() { $$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); }); $('#languageSelect').value = app.language; $('#themeSelect').value = app.theme; - const connectionLabel = $('#connectionLabel'); - if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`); + updateConnectionIndicator(app.connectionStatus); renderAll(); if (app.currentView === 'logs') loadLogs(); if (app.currentView === 'history' && app.zones.length) loadHistory(); @@ -175,6 +185,7 @@ async function loadBootstrap(showMessage = false) { app.automations = data.automations || []; app.accessTokens = data.access_tokens || []; app.settings = data.settings || null; + app.sensorAliases = {...(app.settings?.home_assistant?.sensor_aliases || {})}; app.system = data.system || {}; app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null; renderAll(); @@ -348,8 +359,16 @@ function buildZoneSimulation(zone, planZone) { const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist); const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset); const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget); - const fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed); - const quiet = zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'); + const nightActive = !!app.controlPlan?.night_mode_active; + const nightMaxFan = clamp(Number(app.controlPlan?.night_mode_max_fan_speed || 1), 1, 5); + let fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed); + if (nightActive) { + if (zone?.smart_fan) fanSpeed = fanSpeed === 0 ? 1 : Math.min(fanSpeed ?? nightMaxFan, nightMaxFan); + else if (fanSpeed === 0 || fanSpeed == null || fanSpeed > nightMaxFan) fanSpeed = nightMaxFan; + } + const quiet = nightActive && app.settings?.night_mode?.force_quiet + ? tr('simulation.quietIfSupported') + : (zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual')); const reasoning = !zone?.enabled || planZone?.enabled === false ? tr('simulation.reasonDisabled') : mode === 'off' @@ -373,70 +392,93 @@ function simulationRuleAction(rule) { function renderSimulationPage() { const summaryHost = $('#simulationSummary'); const timelineHost = $('#simulationTimeline'); - const zonesHost = $('#simulationZones'); + const boardHost = $('#simulationFlowBoard'); const rulesHost = $('#simulationRules'); - if (!summaryHost || !timelineHost || !zonesHost || !rulesHost) return; + if (!summaryHost || !timelineHost || !boardHost || !rulesHost) return; const plan = app.controlPlan; if (!plan) { summaryHost.innerHTML = `
${esc(tr('plan.loading'))}
`; - timelineHost.innerHTML = ''; - zonesHost.innerHTML = ''; - rulesHost.innerHTML = ''; + timelineHost.innerHTML = ''; boardHost.innerHTML = ''; rulesHost.innerHTML = ''; return; } + const enabledZones = (plan.zones || []).filter(zone => zone.enabled); const demandingZones = enabledZones.filter(zone => zone.demand); + const nightOn = !!plan.night_mode_active; summaryHost.innerHTML = [ {label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})}, + {label: tr('settings.nightMode'), value: nightOn ? tr('common.active') : (app.settings?.night_mode?.enabled ? tr('simulation.scheduled') : tr('common.disabled')), note: `${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${tr('simulation.fanMax')} ${fanLabel(plan.night_mode_max_fan_speed || 1)}`}, {label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})}, - {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: tr('simulation.requestingZones', {count: demandingZones.length})}, - {label: tr('simulation.rulesLabel'), value: String((plan.rules || []).filter(rule => rule.enabled).length), note: tr('simulation.eventsQueued', {count: (plan.next_events || []).length})}, + {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: `${tr('simulation.requestingZones', {count: demandingZones.length})} · ${tr('simulation.rulesLabel')}: ${(plan.rules || []).filter(rule => rule.enabled).length}`}, ].map(card => `
${esc(card.label)}${esc(card.value)}${esc(card.note)}
`).join(''); + const zones = plan.zones || []; + const nodeW = 190, nodeH = 106, rowH = 190, topY = 155; + const x = {sensor:45, thermostat:305, decision:565, unit:825, event:1085}; + const boardW = 1325, boardH = Math.max(390, topY + zones.length * rowH + 35); + const nodes = []; + const links = []; + const pathBetween = (ax, ay, bx, by, cls='') => { + const bend = Math.max(36, (bx - ax) * .45); + return ``; + }; + const verticalLink = (ax, ay, bx, by, cls='bus') => ``; + const node = ({left, top, kind, eyebrow, title, value, meta='', badge='', badgeClass=''}) => `
${esc(eyebrow)}${badge ? `${esc(badge)}` : ''}

${esc(title)}

${esc(value)}${meta ? `

${esc(meta)}

` : ''}
`; + + const globalHouseLeft = 305, globalNightLeft = 565, globalTop = 28; + nodes.push(node({left:globalHouseLeft,top:globalTop,kind:'logic global',eyebrow:tr('simulation.globalInput'),title:tr('simulation.houseMode'),value:modeLabel(plan.house_mode || 'off'),meta:tr('simulation.strategy',{strategy:plan.control_strategy || 'setpoint'}),badge:tr('simulation.house')})); + nodes.push(node({left:globalNightLeft,top:globalTop,kind:`logic global ${nightOn?'night-active':''}`,eyebrow:tr('simulation.globalInput'),title:tr('settings.nightMode'),value:nightOn?tr('common.active'):(app.settings?.night_mode?.enabled?tr('simulation.scheduled'):tr('common.disabled')),meta:`${plan.night_mode_start || '22:00'} → ${plan.night_mode_end || '06:00'} · ${fanLabel(plan.night_mode_max_fan_speed || 1)}`,badge:nightOn?'☾':'○',badgeClass:nightOn?'active':''})); + + zones.forEach((planZone, index) => { + const zone = app.zones.find(item => item.id === planZone.zone_id); + const sim = buildZoneSimulation(zone, planZone); + const status = simulationStatusInfo(sim.status); + const y = topY + index * rowH; + const mid = y + nodeH / 2; + const next = (planZone.next_events || [])[0]; + const source = zoneControlSourceLabel(planZone.control_source); + const sensorName = planZone.control_source === 'external' || planZone.control_source === 'combined' + ? haSensorLabel(zone?.ha_entity_id || source) + : (planZone.device_name || tr('common.device')); + const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed); + const quietText = sim.quiet; + const command = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText}`; + const eventValue = next ? simulationTime(next.at) : tr('simulation.noEventShort'); + const eventMeta = next ? `${next.label}${next.target_temperature == null ? '' : ` · ${fmtTemp(next.target_temperature)}`}` : tr('plan.noEvents'); + + nodes.push(`
${esc(planZone.zone_name)}${esc(planZone.device_name || '')}
`); + nodes.push(node({left:x.sensor,top:y,kind:'input',eyebrow:tr('simulation.stepRoom'),title:sensorName,value:fmtTemp(sim.current),meta:source,badge:'●',badgeClass:sim.current == null?'':'active'})); + nodes.push(node({left:x.thermostat,top:y,kind:'logic',eyebrow:tr('simulation.thermostat'),title:planZone.zone_name,value:sim.target == null?'—':fmtTemp(sim.target),meta:`${zonePresetLabel(planZone.preset)} · ${tr('simulation.hysteresis',{value:sim.hysteresis.toFixed(1)})}`,badge:modeLabel(sim.mode)})); + nodes.push(node({left:x.decision,top:y,kind:`logic decision ${sim.demand?'demand':'satisfied'}`,eyebrow:tr('simulation.stepDecision'),title:status.label,value:sim.demand?tr('simulation.callForComfort'):tr('simulation.standby'),meta:sim.reasoning,badge:sim.demand?'▶':'✓',badgeClass:status.badge})); + nodes.push(node({left:x.unit,top:y,kind:'action',eyebrow:tr('simulation.stepCommand'),title:planZone.device_name || tr('common.device'),value:command,meta:`${tr('devices.quiet')}: ${quietText}`,badge:sim.demand?'RUN':'IDLE',badgeClass:sim.demand?'active':''})); + nodes.push(node({left:x.event,top:y,kind:'event',eyebrow:tr('simulation.nextEvent'),title:eventValue,value:next?.preset ? zonePresetLabel(next.preset) : tr('simulation.schedule'),meta:eventMeta,badge:'›'})); + + links.push(pathBetween(x.sensor+nodeW,mid,x.thermostat,mid,'input-link')); + links.push(pathBetween(x.thermostat+nodeW,mid,x.decision,mid,'logic-link')); + links.push(pathBetween(x.decision+nodeW,mid,x.unit,mid,sim.demand?'active-link':'logic-link')); + links.push(pathBetween(x.unit+nodeW,mid,x.event,mid,'action-link')); + links.push(verticalLink(globalHouseLeft+nodeW/2,globalTop+nodeH,x.thermostat+nodeW/2,y,'bus')); + if (app.settings?.night_mode?.enabled) links.push(verticalLink(globalNightLeft+nodeW/2,globalTop+nodeH,x.decision+nodeW/2,y,nightOn?'night-link':'bus')); + }); + + boardHost.style.width = `${boardW}px`; + boardHost.style.height = `${boardH}px`; + boardHost.innerHTML = `${nodes.join('')}`; + const timelineItems = []; (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event})); - (plan.zones || []).forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name}))); + zones.forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name}))); timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at)); - const unique = []; - const seen = new Set(); + const unique = [], seen = new Set(); for (const item of timelineItems) { const key = `${item.scope}|${item.event.at}|${item.event.label}`; - if (seen.has(key)) continue; - seen.add(key); - unique.push(item); - if (unique.length >= 16) break; + if (seen.has(key)) continue; seen.add(key); unique.push(item); if (unique.length >= 16) break; } timelineHost.innerHTML = unique.length ? unique.map(item => { const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`; return `
${esc(item.scope)}

${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}

`; }).join('') : `
${esc(tr('plan.noEvents'))}
`; - zonesHost.innerHTML = (plan.zones || []).length ? plan.zones.map(planZone => { - const zone = app.zones.find(item => item.id === planZone.zone_id); - const sim = buildZoneSimulation(zone, planZone); - const status = simulationStatusInfo(sim.status); - const events = (planZone.next_events || []).slice(0, 4); - const sourceLabel = zoneControlSourceLabel(planZone.control_source); - const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed); - const commandText = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText} · ${tr('devices.quiet')}: ${sim.quiet}`; - return `
-
${esc(planZone.device_name || tr('common.noDevice'))}

${esc(planZone.zone_name)}

${esc(modeLabel(sim.mode))} · ${esc(zonePresetLabel(planZone.preset))}${planZone.current_schedule_name ? ` · ${esc(planZone.current_schedule_name)}` : ''}

${esc(status.label)}
-
-
${esc(tr('simulation.stepRoom'))}${esc(fmtTemp(sim.current))}${esc(sourceLabel)}
-
${esc(tr('simulation.stepTarget'))}${esc(sim.target == null ? '—' : `${sim.target.toFixed(1)}°C`)}${esc(tr('simulation.hysteresis', {value: sim.hysteresis.toFixed(1)}))}
-
${esc(tr('simulation.stepDecision'))}${esc(status.label)}${esc(sim.reasoning)}
-
${esc(tr('simulation.stepCommand'))}${esc(commandText)}${esc(sim.demand ? tr('simulation.activeTargetLabel', {value: sim.activeTarget == null ? '—' : sim.activeTarget.toFixed(1)}) : tr('simulation.standbyTargetLabel', {value: sim.standbyTarget == null ? '—' : sim.standbyTarget.toFixed(1)}))}
-
-
-
${esc(tr('simulation.deviceSetpoint'))}${esc(sim.desiredDeviceTarget == null ? '—' : fmtTemp(sim.desiredDeviceTarget))}
-
${esc(tr('common.fan'))}${esc(fanText)}
-
${esc(tr('devices.quiet'))}${esc(sim.quiet)}
-
${esc(tr('simulation.outdoor'))}${esc(sim.outdoor == null ? '—' : fmtTemp(sim.outdoor))}
-
-
    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
-
`; - }).join('') : `
${esc(tr('zones.emptyText'))}
`; - const activeRules = (plan.rules || []).filter(rule => rule.enabled); rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
${esc(tr('common.automation'))}

${esc(rule.name)}

${esc(automationTriggerLabel(rule))}

${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}

${esc(simulationRuleAction(rule))}
${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
`).join('') : `
${esc(tr('simulation.noRules'))}
`; } @@ -451,7 +493,7 @@ function deviceCard(device, detailed = false) { const fans = [0,1,3,5]; const error = device.last_error ? `${esc(device.last_error)}` - : `${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}`; + : `${esc(device.ip)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}`; return `

${esc(device.name)}

${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

@@ -470,7 +512,7 @@ function deviceCard(device, detailed = false) {
- ${detailed ? `` : ''} + ${detailed ? `` : ''}
`; } @@ -577,6 +619,32 @@ function renderAccessTokens() { `).join('') : `
${esc(tr('settings.noTokens'))}${esc(tr('settings.noTokensHint'))}
`; } +function knownHaEntities() { + return [...new Set([ + ...Object.keys(app.sensorAliases || {}), + ...app.zones.map(zone => zone.ha_entity_id).filter(Boolean), + app.settings?.home_assistant?.default_entity_id, + app.settings?.home_assistant?.outdoor_entity_id, + ...app.historyData.sensors.map(row => row.entity_id), + ].filter(Boolean))].sort(); +} + +function renderSensorAliases() { + const host = $('#sensorAliasList'); if (!host) return; + const entities = knownHaEntities(); + host.innerHTML = entities.length ? entities.map(entity => `
${esc(entity)}
`).join('') : `
${esc(tr('settings.noSensorAliases'))}
`; +} + +function renderLogRetention() { + const select = $('#logRetentionDays'); if (!select || !app.settings) return; + [...select.options].forEach(option => { option.textContent = `${option.value} ${tr('common.days')}`; }); + const days = String(app.settings.event_log_retention_days || 30); + if (![...select.options].some(option => option.value === days)) { + const option = document.createElement('option'); option.value = days; option.textContent = `${days} ${tr('common.days')}`; select.appendChild(option); + } + select.value = days; +} + function renderSettings() { if (!app.settings) return; const form = $('#settingsForm'); @@ -587,8 +655,14 @@ function renderSettings() { form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000; form.simulator_enabled.checked = !!app.settings.simulator_enabled; form.history_retention_days.value = app.settings.history_retention_days || 30; + form.event_log_retention_days.value = app.settings.event_log_retention_days || 30; form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false; form.suppress_device_beep.checked = !!app.settings.suppress_device_beep; + form.night_mode_enabled.checked = !!app.settings.night_mode?.enabled; + form.night_mode_start.value = app.settings.night_mode?.start_time || '22:00'; + form.night_mode_end.value = app.settings.night_mode?.end_time || '06:00'; + form.night_mode_max_fan_speed.value = String(app.settings.night_mode?.max_fan_speed || 1); + form.night_mode_force_quiet.checked = app.settings.night_mode?.force_quiet !== false; form.influx_enabled.checked = !!app.settings.influxdb?.enabled; form.influx_version.value = String(app.settings.influxdb?.version || '2'); form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30; @@ -611,6 +685,8 @@ function renderSettings() { form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls; form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled; updateInfluxFields(); + renderSensorAliases(); + renderLogRetention(); $('#systemInfo').innerHTML = `

${esc(tr('settings.systemState'))}

${esc(tr('settings.version'))}: ${esc(app.system.version || '—')}
${esc(tr('settings.uptime'))}: ${esc(formatDuration(app.system.uptime_seconds || 0))}
${esc(tr('settings.apiAuth'))}: ${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}
`; } @@ -855,7 +931,7 @@ function historyEntityOptions() { ...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 => ``).join(''); + const sensors = entities.map(entity => ``).join(''); return {zones, devices, sensors, entities}; } @@ -1004,7 +1080,7 @@ function renderOverviewHistory() { 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 outdoorHaSeries=outdoorEntities.map((entity,index)=>({label:`HA · ${haSensorLabel(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); @@ -1063,7 +1139,7 @@ function renderSensorHistory() { 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})); + const series=entities.map((entity,index)=>({label:haSensorLabel(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); } @@ -1082,7 +1158,7 @@ function customSeriesOptions() { 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}`])); + [...new Set(app.historyData.sensors.map(row=>row.entity_id))].forEach(entity=>items.push([`ha|${entity}|temperature`,`HA · ${haSensorLabel(entity)}`])); return items; } @@ -1101,7 +1177,7 @@ function customSeriesDefinition(key,index=0) { 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}; + if(kind==='ha') return {key,label:`HA · ${haSensorLabel(id)}`,color,dash:[3,4],value:row=>row.entity_id===id?historyNumber(row.temperature):NaN}; return null; } @@ -1180,6 +1256,7 @@ async function handleHistoryAction(button) { } async function loadLogs() { + renderLogRetention(); try { const data = await api('/api/events?limit=150'); const logs = data.events || []; @@ -1195,21 +1272,21 @@ function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const query = app.token ? `?token=${encodeURIComponent(app.token)}` : ''; const ws = new WebSocket(`${protocol}//${location.host}/ws${query}`); app.ws = ws; - ws.onopen = () => { app.connectionStatus = 'connected'; $('#connectionLabel').textContent = tr('status.connected'); }; - ws.onclose = () => { app.connectionStatus = 'disconnected'; $('#connectionLabel').textContent = tr('status.disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; - ws.onerror = () => { app.connectionStatus = 'connectionError'; $('#connectionLabel').textContent = tr('status.connectionError'); }; + ws.onopen = () => updateConnectionIndicator('connected'); + ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; + ws.onerror = () => updateConnectionIndicator('connectionError'); ws.onmessage = event => { try { const message = JSON.parse(event.data); if (message.event === 'bootstrap') { - const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; + const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return; } const data = message.data || {}; if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); scheduleControlPlanLoad(); } else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); } else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); } else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); scheduleControlPlanLoad(); } - else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); } + else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); } else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); } else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); } else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } @@ -1378,7 +1455,12 @@ function settingsBodyFromForm(form) { discovery_timeout_ms:Number(raw.discovery_timeout_ms), discovery_broadcast:raw.discovery_broadcast, house_mode:app.settings?.house_mode||'cool', control_strategy:'setpoint', outdoor_assist_enabled:form.outdoor_assist_enabled.checked, history_retention_days:Number(raw.history_retention_days), history_compaction_enabled:form.history_compaction_enabled.checked, + event_log_retention_days:Number(raw.event_log_retention_days), suppress_device_beep:form.suppress_device_beep.checked, + night_mode:{ + enabled:form.night_mode_enabled.checked,start_time:raw.night_mode_start,end_time:raw.night_mode_end, + max_fan_speed:Number(raw.night_mode_max_fan_speed),force_quiet:form.night_mode_force_quiet.checked, + }, influxdb:{ enabled:form.influx_enabled.checked, version:raw.influx_version, url:raw.influx_url, database:raw.influx_database, username:raw.influx_username, password:raw.influx_password, @@ -1386,18 +1468,49 @@ function settingsBodyFromForm(form) { history_threshold_days:Number(raw.influx_threshold_days), }, debug:{overlay_enabled:form.debug_overlay_enabled.checked, gree_frames:form.debug_gree_frames.checked}, - home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked}, + home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked,sensor_aliases:{...(app.sensorAliases||{})}}, }; } async function saveSettingsForm(form, notify=true) { - app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings; + app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderHouseClimate(); renderDebugOverlay(); scheduleControlPlanLoad(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); if(notify) toast(tr('common.saved')); return app.settings; } $('#settingsForm').addEventListener('submit', async event => { event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } catch(error){toast(error.message,true);} }); +$('#addSensorAlias')?.addEventListener('click', () => { + const entityInput = $('#sensorAliasEntity'), aliasInput = $('#sensorAliasName'); + const entity = entityInput.value.trim(), alias = aliasInput.value.trim(); + if (!entity || !alias) return toast(tr('settings.aliasRequired'), true); + app.sensorAliases[entity] = alias; + entityInput.value = ''; aliasInput.value = ''; + renderSensorAliases(); renderHistoryNavigation(); +}); + +document.addEventListener('input', event => { + const input = event.target.closest('[data-sensor-alias]'); if (!input) return; + const entity = input.dataset.sensorAlias, alias = input.value.trim(); + if (alias) app.sensorAliases[entity] = alias; else delete app.sensorAliases[entity]; +}); + +document.addEventListener('click', event => { + const button = event.target.closest('[data-clear-sensor-alias]'); if (!button) return; + delete app.sensorAliases[button.dataset.clearSensorAlias]; + renderSensorAliases(); renderHistoryNavigation(); +}); + +$('#saveLogRetention')?.addEventListener('click', async () => { + const days = Number($('#logRetentionDays')?.value || 30); + try { + const result = await api('/api/events/retention', {method:'PUT', body:{days}}); + app.settings.event_log_retention_days = result.days; + $('#settingsForm').event_log_retention_days.value = result.days; + renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', {days: result.days})); + } catch (error) { toast(error.message, true); } +}); + $('#createAccessToken').addEventListener('click', async event => { const button = event.currentTarget; button.disabled = true; diff --git a/web/index.html b/web/index.html index 9269199..9488bfa 100644 --- a/web/index.html +++ b/web/index.html @@ -23,7 +23,7 @@
-
GREE ControllerConnecting…
+
GREE Controller
+ +
@@ -135,72 +137,109 @@
System

Settings

-
-

Controller

- - - - - - -
-

Metrics storage

-

SQLite keeps recent data locally and compacts older samples to the resolution used by charts.

- - -
-

GREE commands

- -

Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.

-
-

Long-term InfluxDB history

-

Optional archive for older history. InfluxDB 1.x and 2.x are supported.

- - - - -
- - - -
-
- - - -
-
-

On-screen debug

- - -
-

Home Assistant sensor input

-

Optional. Used only when a room zone reads an external Home Assistant temperature sensor.

- - - - - - -

Use only for a trusted local Home Assistant server, for example https://10.87.65.2.

-
-
-

Home Assistant integration access

-

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

-
-
-
-
-
-

Configuration backup

-

Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.

-
+ +
+

Controller

Runtime and LAN discovery settings.

+
+ + + + + + +
+
+ +
+

Night mode

During selected hours the thermostat limits fan speed and can request Quiet on supported units.

+
+ + + + + +
+
+ +
+

Metrics and logs

SQLite keeps recent data locally and compacts older samples to the resolution used by charts.

+
+ + + +
+
+ +
+

GREE commands

Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.

+
+
+ +
+

Long-term InfluxDB history

Optional archive for older history. InfluxDB 1.x and 2.x are supported.

+
+ + + + +
+ + + +
+
+ + + +
+
+
+ +
+

On-screen debug

Live diagnostics displayed on every application page.

+
+ + +
+
+ +
+
HA

Home Assistant sensor input

Optional. Used when a room zone reads an external Home Assistant temperature sensor.

+
+ + + + + + +

Use only for a trusted local Home Assistant server, for example https://192.168.50.25.

+
+
Sensor aliasesFriendly names are used in charts and selectors; the original entity_id remains unchanged.
+
+
+
+
+
+
+ +
+

Home Assistant integration access

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

+
+
+ +
+

Configuration backup

Export/import application configuration. Exported files can contain GREE device keys plus Home Assistant and InfluxDB secrets; metrics and API access tokens are not included.

+
+
+ +
Save changes made in the blocks above.
Diagnostics

Events

+
Log retentionOld event rows are removed automatically during maintenance.
@@ -260,7 +299,7 @@
-

Rename device

+

Name / protocol

Changing protocol clears the saved device key and performs a new bind on the next request.

diff --git a/web/styles.css b/web/styles.css index 98e2456..28e60ad 100644 --- a/web/styles.css +++ b/web/styles.css @@ -506,3 +506,102 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si .simulation-timeline-item { grid-template-columns:1fr; gap:6px; } .simulation-flow, .simulation-metrics { grid-template-columns:1fr; } } + +/* Compact connection state: a colored dot replaces the Connected/Disconnected label. */ +.brand-line { display:flex; align-items:center; gap:8px; min-width:0; } +.brand-line strong { display:block; } +.connection-dot { display:inline-block !important; width:9px; height:9px; flex:0 0 9px; margin:0 !important; border-radius:50%; background:var(--muted); box-shadow:0 0 0 3px color-mix(in srgb, var(--muted) 15%, transparent); } +.connection-dot.connected { background:#37c96b; box-shadow:0 0 0 3px rgba(55,201,107,.14), 0 0 10px rgba(55,201,107,.3); } +.connection-dot.disconnected, .connection-dot.connectionError { background:#ef5b5b; box-shadow:0 0 0 3px rgba(239,91,91,.14), 0 0 10px rgba(239,91,91,.24); } +.connection-dot.connecting { background:#e2a93b; box-shadow:0 0 0 3px rgba(226,169,59,.14); animation:connection-pulse 1.25s ease-in-out infinite; } +@keyframes connection-pulse { 50% { opacity:.45; transform:scale(.86); } } + +/* v0.5.3 usability refinements */ +.history-toolbar-panel .chart-toolbar { display:grid; grid-template-columns:minmax(260px,1fr) minmax(180px,.55fr) auto; gap:12px; align-items:end; } +.history-toolbar-panel .chart-toolbar > * { min-width:0; } +.history-context-controls { display:block; width:100%; min-width:0; } +.history-context-controls label, .history-range-control { width:100%; } +.history-range-control { display:grid; gap:7px; } +.history-refresh-button { min-height:45px; align-self:end; } + +.settings-form { display:grid; gap:14px; } +.settings-block { display:grid; gap:18px; } +.settings-block-head { display:flex; align-items:flex-start; gap:13px; } +.settings-block-head h3 { margin:1px 0 4px; font-size:18px; } +.settings-block-head p { margin:0; color:var(--muted); font-size:12px; line-height:1.5; } +.settings-block-icon { display:grid; place-items:center; width:34px; height:34px; flex:0 0 34px; border:1px solid var(--line); border-radius:11px; color:var(--accent); background:var(--surface-muted); font-size:14px; font-weight:800; } +.settings-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; } +.settings-grid .wide { grid-column:1/-1; } +.settings-subhead { display:flex; justify-content:space-between; gap:12px; margin-bottom:10px; } +.settings-subhead strong, .settings-subhead small { display:block; } +.settings-subhead small { margin-top:4px; color:var(--muted); font-size:11px; line-height:1.4; } +.settings-save-bar { position:sticky; bottom:18px; z-index:9; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:13px 15px; border:1px solid var(--line-strong); border-radius:16px; background:color-mix(in srgb,var(--surface) 94%,transparent); box-shadow:0 8px 30px rgba(0,0,0,.18); backdrop-filter:blur(16px); } +.settings-save-bar span { color:var(--muted); font-size:12px; } +.sensor-alias-manager { padding:14px; border:1px solid var(--line); border-radius:16px; background:var(--surface-muted); } +.sensor-alias-list { display:grid; gap:8px; } +.sensor-alias-row { display:grid; grid-template-columns:minmax(180px,1fr) minmax(150px,1fr) 34px; gap:8px; align-items:center; } +.sensor-alias-row > span { overflow:hidden; color:var(--muted); font-size:11px; text-overflow:ellipsis; white-space:nowrap; } +.sensor-alias-row input { min-height:40px; } +.sensor-alias-clear { width:34px; height:34px; padding:0; border-radius:10px; color:var(--muted); background:transparent; } +.sensor-alias-add { display:grid; grid-template-columns:minmax(180px,1fr) minmax(150px,1fr) auto; gap:8px; margin-top:10px; } +.logs-toolbar { display:grid; grid-template-columns:1fr minmax(160px,220px) auto; gap:14px; align-items:end; margin-bottom:14px; } +.logs-toolbar > div strong, .logs-toolbar > div small { display:block; } +.logs-toolbar > div small { margin-top:4px; color:var(--muted); font-size:11px; line-height:1.4; } + +.simulation-flow-shell { padding:0; overflow:hidden; margin-bottom:18px; } +.simulation-flow-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; padding:18px 18px 14px; border-bottom:1px solid var(--line); } +.simulation-flow-head h2 { margin:3px 0 0; } +.flow-legend { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); font-size:11px; } +.flow-legend span { display:flex; align-items:center; gap:6px; } +.flow-legend b { font-weight:650; } +.flow-legend-dot { width:8px; height:8px; border-radius:50%; background:var(--muted); } +.flow-legend-dot.input { background:var(--teal); } +.flow-legend-dot.logic { background:var(--purple); } +.flow-legend-dot.action { background:var(--accent); } +.simulation-flow-scroll { overflow:auto; max-width:100%; scrollbar-width:thin; } +.simulation-flow-board { position:relative; min-width:1325px; background-color:var(--surface-muted); background-image:radial-gradient(circle,var(--grid) 1px,transparent 1.5px); background-size:20px 20px; } +.flow-links { position:absolute; inset:0; z-index:1; pointer-events:none; overflow:visible; } +.flow-link { fill:none; stroke:color-mix(in srgb,var(--muted) 48%,transparent); stroke-width:2; vector-effect:non-scaling-stroke; } +.flow-link.bus { stroke-dasharray:5 6; opacity:.55; } +.flow-link.input-link { stroke:color-mix(in srgb,var(--teal) 65%,var(--muted)); } +.flow-link.logic-link { stroke:color-mix(in srgb,var(--purple) 58%,var(--muted)); } +.flow-link.action-link { stroke:color-mix(in srgb,var(--accent) 60%,var(--muted)); } +.flow-link.active-link { stroke:var(--accent); stroke-width:3; filter:drop-shadow(0 0 4px color-mix(in srgb,var(--accent) 35%,transparent)); } +.flow-link.night-link { stroke:var(--info); stroke-dasharray:6 5; stroke-width:2.5; } +.flow-lane-label { position:absolute; z-index:2; left:45px; width:1230px; display:flex; align-items:center; gap:9px; color:var(--muted); font-size:11px; } +.flow-lane-label::after { content:""; height:1px; flex:1; background:var(--line); } +.flow-lane-label strong { color:var(--text-soft); font-size:12px; } +.flow-node { position:absolute; z-index:3; display:grid; align-content:start; gap:6px; padding:12px 13px; border:1px solid var(--line-strong); border-radius:12px; background:color-mix(in srgb,var(--surface) 96%,transparent); box-shadow:0 8px 24px rgba(0,0,0,.15); } +.flow-node.input { border-left:3px solid var(--teal); } +.flow-node.logic { border-left:3px solid var(--purple); } +.flow-node.action { border-left:3px solid var(--accent); } +.flow-node.event { border-left:3px solid var(--info); } +.flow-node.decision.demand { box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 28%,transparent),0 8px 24px rgba(0,0,0,.15); } +.flow-node.global { min-height:106px; } +.flow-node.night-active { border-color:color-mix(in srgb,var(--info) 45%,var(--line-strong)); } +.flow-node-top { display:flex; align-items:center; justify-content:space-between; gap:6px; color:var(--muted); font-size:9px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; } +.flow-node h3 { overflow:hidden; margin:0; color:var(--text); font-size:13px; text-overflow:ellipsis; white-space:nowrap; } +.flow-node > strong { color:var(--text); font-size:17px; line-height:1.25; } +.flow-node p { display:-webkit-box; overflow:hidden; margin:0; color:var(--muted); font-size:10px; line-height:1.35; -webkit-box-orient:vertical; -webkit-line-clamp:2; } +.flow-node-badge { max-width:86px; overflow:hidden; padding:3px 6px; border-radius:999px; color:var(--muted); background:var(--surface-muted); font-size:8px; text-overflow:ellipsis; white-space:nowrap; } +.flow-node-badge.active { color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,var(--surface)); } +.flow-port { position:absolute; top:50%; width:9px; height:9px; margin-top:-5px; border:2px solid var(--surface); border-radius:50%; background:var(--muted); } +.flow-port.in { left:-6px; } +.flow-port.out { right:-6px; } +.flow-node.input .flow-port { background:var(--teal); } +.flow-node.logic .flow-port { background:var(--purple); } +.flow-node.action .flow-port { background:var(--accent); } +.flow-node.event .flow-port { background:var(--info); } + +@media (max-width: 760px) { + .history-toolbar-panel .chart-toolbar { grid-template-columns:1fr 1fr; } + .history-context-controls { grid-column:1/-1; } + .history-refresh-button { grid-column:auto; } + .settings-grid { grid-template-columns:1fr; } + .settings-grid .wide { grid-column:auto; } + .settings-save-bar { bottom:76px; } + .sensor-alias-row, .sensor-alias-add { grid-template-columns:1fr; } + .sensor-alias-clear { justify-self:end; } + .logs-toolbar { grid-template-columns:1fr; align-items:stretch; } + .simulation-flow-head { align-items:flex-start; flex-direction:column; } +} diff --git a/web/sw.js b/web/sw.js index 6fa9c1d..f7d9ea3 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'gree-controller-v044'; +const CACHE = 'gree-controller-v053'; 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())));