This commit is contained in:
Mateusz Gruszczyński
2026-09-02 09:17:56 +02:00
parent 43441d2fb7
commit 87b68187e7
13 changed files with 119 additions and 58 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.9.11" version = "0.9.13"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.9.11" version = "0.9.13"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+8 -8
View File
@@ -1,11 +1,11 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
71d5043c5c5892bcf5ad33dd0ae73600bc0ff9bbc10d93e963c90c173963c39e ./Cargo.lock 18765f4d1c77c7c44ad4438aa3d31e90030577f5395190d0ec1450c0fadd5987 ./Cargo.lock
c446b9af75b3d07cdf2c42b7f1286f503b225d5dc2ed3592cce4df74de31db21 ./Cargo.toml d3e13a2ef72326abae6de8eb727d858d191bb2eeb1a1680e2c028c2b560f13ea ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
21b3a4808f6d47b3cb283be03faf8ec1327f2a757b247277c0299468c8a0d74c ./README.md 6955218013ff72cc2b92b930e664f0215d067c0ea88f928df09d244b69958ba9 ./README.md
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs 41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
05eafc8541a867aba3121bd0df1ba475305005841c8a2437a667484a232e80e4 ./docs/API.md b232adc48c1737bd2a7d6ee47c97ff17750243027f6737ae8209f9111c8e819c ./docs/API.md
5322117989c854763d87a4ea6ec5e957863d370ca4ebdc332e2c8c11f23338d1 ./docs/FLOW.md 5322117989c854763d87a4ea6ec5e957863d370ca4ebdc332e2c8c11f23338d1 ./docs/FLOW.md
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
@@ -17,7 +17,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
b4a1da0fc4912485c419f53b07dc651910a5cffb23f19b9a3290be7ae1946478 ./home-assistant/custom_components/gree_controller/manifest.json 3593dc4b937182dcf7d372c8710f079d99f25304f6a17ab7d5b84508127047ce ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
@@ -171,7 +171,7 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
460c3f8599feaf87deb854766b1d815069934132140fd3c48b5c81fe62957415 ./web/index.html 0c10450b9241a9b7a811fe8ad44b4413018e33fcd5b932f024a73e1ba6a3f946 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js 21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js 72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
@@ -179,7 +179,7 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.
d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js
2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js 2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js 9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
7373214514dc553c6c793afc88aa62f4c21b02c32e002a81f27d7aeabca82fdf ./web/js/flows.js ee094f55b3f6d0c40fafaf422776ee87b17e0d83eb228ca6c197dfd3efd4c774 ./web/js/flows.js
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js 7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
@@ -189,6 +189,6 @@ c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/route
3d44b5457200a71f66fbf7bde89c7b9adf416ca185ad0e3c61f2c4e551bceb1e ./web/js/settings-ui.js 3d44b5457200a71f66fbf7bde89c7b9adf416ca185ad0e3c61f2c4e551bceb1e ./web/js/settings-ui.js
a06d718217dc6ffb6d4fb5b002fedac02ba7eea3a5b00066c2241df7c1957e51 ./web/js/settings.js a06d718217dc6ffb6d4fb5b002fedac02ba7eea3a5b00066c2241df7c1957e51 ./web/js/settings.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
d6a7ebd22557a1dc8c74b14562a3f541c77bc0afcae88508a8e667c4ddbb775e ./web/styles.css c8ca437e032fe81327f3bfe7457b5510f21bad21c021bfc83caf44527ce9633f ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+1 -1
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.9.11** **Current release: 0.9.13**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+3 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference # GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.9.11**. HTTP and WebSocket API for GREE Controller **0.9.13**.
[← Main documentation](../README.md) [← Main documentation](../README.md)
@@ -213,7 +213,7 @@ Response:
{ {
"status": "ok", "status": "ok",
"name": "gree-controller", "name": "gree-controller",
"version": "0.9.11", "version": "0.9.13",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"control_ready": true, "control_ready": true,
"time": "2026-08-30T06:54:00Z" "time": "2026-08-30T06:54:00Z"
@@ -237,7 +237,7 @@ Returns the initial Web UI snapshot:
"settings": {}, "settings": {},
"outdoor_temperature": null, "outdoor_temperature": null,
"system": { "system": {
"version": "0.9.11", "version": "0.9.13",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"auth_required": false, "auth_required": false,
"control_ready": true, "control_ready": true,
+4
View File
@@ -88,3 +88,7 @@ The next useful additions should be implemented with persisted runtime state rat
- trace replay from historical sensor samples. - trace replay from historical sensor samples.
These need durable state and restart semantics before they should be exposed in the editor. These need durable state and restart semantics before they should be exposed in the editor.
### Display name / alias
The Flow name edited in `/flows` is a presentation alias. Renaming it changes labels shown in the UI (including the dashboard) but does not change the stable Flow ID or generated `flow-<unique-id>` schedule/automation names.
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.9.11", "version": "0.9.13",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
+4 -3
View File
@@ -823,7 +823,7 @@
"flow.emptyTitle": "No Flows", "flow.emptyTitle": "No Flows",
"flow.emptyText": "Create your first Flow and build the logic from blocks.", "flow.emptyText": "Create your first Flow and build the logic from blocks.",
"flow.editorEyebrow": "Flow editor", "flow.editorEyebrow": "Flow editor",
"flow.namePlaceholder": "Logic name", "flow.namePlaceholder": "Flow name / alias",
"flow.paletteHint": "Add a block, then connect its output to the next block.", "flow.paletteHint": "Add a block, then connect its output to the next block.",
"flow.time": "Time", "flow.time": "Time",
"flow.sensors": "Sensors", "flow.sensors": "Sensors",
@@ -847,7 +847,7 @@
"flow.noActionsYet": "Add an action to see the interpreted logic.", "flow.noActionsYet": "Add an action to see the interpreted logic.",
"flow.always": "Always", "flow.always": "Always",
"flow.compileCount": "{schedules} schedules · {automations} automations", "flow.compileCount": "{schedules} schedules · {automations} automations",
"flow.nameRequired": "Enter a Flow name", "flow.nameRequired": "Enter a Flow name / alias",
"flow.saved": "Flow saved and translated into executable logic.", "flow.saved": "Flow saved and translated into executable logic.",
"flow.deleteConfirm": "Delete Flow “{name}” together with its generated schedules and automations?", "flow.deleteConfirm": "Delete Flow “{name}” together with its generated schedules and automations?",
"flow.deleted": "Flow deleted.", "flow.deleted": "Flow deleted.",
@@ -1041,6 +1041,7 @@
"flow.logMessageBlockedTemporary": "The Flow action was blocked by the temporary thermostat.", "flow.logMessageBlockedTemporary": "The Flow action was blocked by the temporary thermostat.",
"flow.logMessageBlockedThermostatOwner": "The Flow action was suppressed because the device is controlled by an enabled thermostat zone.", "flow.logMessageBlockedThermostatOwner": "The Flow action was suppressed because the device is controlled by an enabled thermostat zone.",
"flow.logMessageBlockedOwnership": "The Flow action was suppressed because control ownership changed during execution.", "flow.logMessageBlockedOwnership": "The Flow action was suppressed because control ownership changed during execution.",
"flow.logMessageConflict": "The Flow action was skipped because another automation already claimed the same target." "flow.logMessageConflict": "The Flow action was skipped because another automation already claimed the same target.",
"flow.nameAliasHint": "This is only the user-facing display name. The technical Flow ID and generated task names remain unchanged."
} }
} }
+4 -3
View File
@@ -823,7 +823,7 @@
"flow.emptyTitle": "Brak Flow", "flow.emptyTitle": "Brak Flow",
"flow.emptyText": "Utwórz pierwszy przepływ i ułóż logikę z bloków.", "flow.emptyText": "Utwórz pierwszy przepływ i ułóż logikę z bloków.",
"flow.editorEyebrow": "Edytor Flow", "flow.editorEyebrow": "Edytor Flow",
"flow.namePlaceholder": "Nazwa logiki", "flow.namePlaceholder": "Nazwa / alias Flow",
"flow.paletteHint": "Dodaj blok, a potem połącz jego wyjście z kolejnym blokiem.", "flow.paletteHint": "Dodaj blok, a potem połącz jego wyjście z kolejnym blokiem.",
"flow.time": "Czas", "flow.time": "Czas",
"flow.sensors": "Czujniki", "flow.sensors": "Czujniki",
@@ -847,7 +847,7 @@
"flow.noActionsYet": "Dodaj akcję, aby zobaczyć interpretację logiki.", "flow.noActionsYet": "Dodaj akcję, aby zobaczyć interpretację logiki.",
"flow.always": "Zawsze", "flow.always": "Zawsze",
"flow.compileCount": "{schedules} harm. · {automations} autom.", "flow.compileCount": "{schedules} harm. · {automations} autom.",
"flow.nameRequired": "Podaj nazwę Flow", "flow.nameRequired": "Podaj nazwę / alias Flow",
"flow.saved": "Flow zapisany i przetłumaczony na logikę wykonawczą.", "flow.saved": "Flow zapisany i przetłumaczony na logikę wykonawczą.",
"flow.deleteConfirm": "Usunąć Flow „{name}” razem z wygenerowanymi harmonogramami i automatyzacjami?", "flow.deleteConfirm": "Usunąć Flow „{name}” razem z wygenerowanymi harmonogramami i automatyzacjami?",
"flow.deleted": "Flow usunięty.", "flow.deleted": "Flow usunięty.",
@@ -1041,6 +1041,7 @@
"flow.logMessageBlockedTemporary": "Akcja Flow została zablokowana przez tymczasowy termostat.", "flow.logMessageBlockedTemporary": "Akcja Flow została zablokowana przez tymczasowy termostat.",
"flow.logMessageBlockedThermostatOwner": "Akcja Flow została pominięta, ponieważ urządzeniem steruje aktywna strefa termostatu.", "flow.logMessageBlockedThermostatOwner": "Akcja Flow została pominięta, ponieważ urządzeniem steruje aktywna strefa termostatu.",
"flow.logMessageBlockedOwnership": "Akcja Flow została pominięta, ponieważ w trakcie wykonania zmienił się właściciel sterowania.", "flow.logMessageBlockedOwnership": "Akcja Flow została pominięta, ponieważ w trakcie wykonania zmienił się właściciel sterowania.",
"flow.logMessageConflict": "Akcja Flow została pominięta z powodu konfliktu z inną automatyzacją dla tego samego celu." "flow.logMessageConflict": "Akcja Flow została pominięta z powodu konfliktu z inną automatyzacją dla tego samego celu.",
"flow.nameAliasHint": "To tylko nazwa wyświetlana użytkownikowi. Techniczne ID Flow i nazwy wygenerowanych zadań pozostają bez zmian."
} }
} }
+26 -22
View File
@@ -88,7 +88,7 @@
<div class="dashboard-global-divider"></div> <div class="dashboard-global-divider"></div>
<div class="house-climate dashboard-house-climate" id="houseClimate"></div> <div class="house-climate dashboard-house-climate" id="houseClimate"></div>
</div> </div>
<section class="panel dashboard-section-panel"> <section class="panel dashboard-section-panel" id="controlPlanSection">
<div class="dashboard-section-head compact-head"> <div class="dashboard-section-head compact-head">
<div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span> <div><span class="eyebrow" data-i18n="plan.eyebrow">Control plan</span>
<h2 data-i18n="plan.title">What happens next</h2> <h2 data-i18n="plan.title">What happens next</h2>
@@ -693,7 +693,7 @@
<section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor"> <section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor">
<header class="flow-editor-bar"> <header class="flow-editor-bar">
<div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor" aria-label="Back"></button><div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" placeholder="My automation"></div></div> <div class="flow-editor-title"><button type="button" class="icon-button" data-action="close-flow-editor" aria-label="Back"></button><div><span class="eyebrow" data-i18n="flow.editorEyebrow">Flow editor</span><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" data-i18n-title="flow.nameAliasHint" placeholder="My automation"></div></div>
<div class="flow-editor-actions"><button class="secondary" type="button" data-action="flow-templates" data-i18n="flow.templates">Templates</button><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import</button><button class="secondary" type="button" data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary" type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button class="secondary" type="button" data-action="flow-logs" data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled" checked><span data-i18n="common.enabled">Enabled</span></label><span id="flowCompileStatus" class="badge"></span><button class="primary" type="button" data-action="save-flow" data-i18n="actions.save">Save</button></div> <div class="flow-editor-actions"><button class="secondary" type="button" data-action="flow-templates" data-i18n="flow.templates">Templates</button><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import</button><button class="secondary" type="button" data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary" type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button class="secondary" type="button" data-action="flow-logs" data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled" checked><span data-i18n="common.enabled">Enabled</span></label><span id="flowCompileStatus" class="badge"></span><button class="primary" type="button" data-action="save-flow" data-i18n="actions.save">Save</button></div>
</header> </header>
<div class="flow-editor-body"> <div class="flow-editor-body">
@@ -721,7 +721,7 @@
<input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden> <input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden>
<dialog id="flowSharedInputDialog" class="flow-tool-dialog"> <dialog id="flowSharedInputDialog">
<form id="flowSharedInputForm" class="dialog-form"> <form id="flowSharedInputForm" class="dialog-form">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.sharedInputEyebrow">Wspólne dane</span><h2 id="flowSharedInputDialogTitle" data-i18n="flow.sharedInputTitle">Wspólne wejście Flow</h2></div><button type="button" data-close>×</button></div> <div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.sharedInputEyebrow">Wspólne dane</span><h2 id="flowSharedInputDialogTitle" data-i18n="flow.sharedInputTitle">Wspólne wejście Flow</h2></div><button type="button" data-close>×</button></div>
<input type="hidden" name="id"> <input type="hidden" name="id">
@@ -733,32 +733,36 @@
<button type="button" class="secondary" id="flowSharedInputTest" data-i18n="flow.sharedInputTest">Testuj wejście HA</button> <button type="button" class="secondary" id="flowSharedInputTest" data-i18n="flow.sharedInputTest">Testuj wejście HA</button>
<div id="flowSharedInputTestResult" class="flow-shared-test-result" hidden></div> <div id="flowSharedInputTestResult" class="flow-shared-test-result" hidden></div>
</div> </div>
<div class="dialog-actions flow-shared-dialog-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div> <div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
</form> </form>
</dialog> </dialog>
<dialog id="flowTemplateDialog" class="flow-tool-dialog"> <dialog id="flowTemplateDialog" class="dialog-wide">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div> <div class="dialog-form">
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p> <div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
<div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch" placeholder="Szukaj presetów…"></label></div> <p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
<div class="flow-template-browser"> <div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch" placeholder="Szukaj presetów…"></label></div>
<div class="flow-template-catalog"> <div class="flow-template-browser">
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div> <div class="flow-template-catalog">
<div id="flowTemplateList" class="flow-template-list"></div> <div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
<div id="flowTemplateList" class="flow-template-list"></div>
</div>
<aside id="flowTemplatePreview" class="flow-template-preview"><div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div></aside>
</div> </div>
<aside id="flowTemplatePreview" class="flow-template-preview"><div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div></aside>
</div> </div>
</dialog> </dialog>
<dialog id="flowTestDialog" class="flow-tool-dialog"> <dialog id="flowTestDialog" class="dialog-wide">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span><h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2></div><button type="button" data-close>×</button></div> <div class="dialog-form">
<div id="flowSimulationControls" class="flow-simulation-controls"> <div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.testMode">Flow diagnostics</span><h2 id="flowTestTitle" data-i18n="flow.dryRun">Dry-run</h2></div><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or automation state.</p> <div id="flowSimulationControls" class="flow-simulation-controls">
<label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt" type="datetime-local"></label> <p class="field-note" data-i18n="flow.dryRunHint">Dry-run does not change thermostat, device, group, schedule or automation state.</p>
<div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong><p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or Home Assistant state.</p><div id="flowSimulationOverrides" class="flow-simulation-overrides"></div></div> <label><span data-i18n="flow.simulationTime">Simulation date and time</span><input id="flowSimulationAt" type="datetime-local"></label>
<div class="dialog-actions"><button class="primary" type="button" data-action="run-flow-dry-run" data-i18n="flow.runSimulation">Run simulation</button></div> <div><strong data-i18n="flow.simulationOverrides">Sensor overrides</strong><p class="field-note" data-i18n="flow.simulationOverridesHint">Leave blank to use the current application or Home Assistant state.</p><div id="flowSimulationOverrides" class="flow-simulation-overrides"></div></div>
<div class="form-actions"><button class="primary" type="button" data-action="run-flow-dry-run" data-i18n="flow.runSimulation">Run simulation</button></div>
</div>
<div id="flowTestResults" class="flow-test-results"></div>
</div> </div>
<div id="flowTestResults" class="flow-test-results"></div>
</dialog> </dialog>
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation"> <nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
@@ -906,7 +910,7 @@
data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5"
name="heat_away_setpoint" value="17"></label></div> name="heat_away_setpoint" value="17"></label></div>
</details> </details>
<div class="two"> <div class="two zone-hysteresis-row">
<div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis <div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis
°C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis"
value="0.6"></label><label class="check" data-i18n-title="zones.separateHysteresisHint" value="0.6"></label><label class="check" data-i18n-title="zones.separateHysteresisHint"
+18 -5
View File
@@ -78,15 +78,17 @@ function planZoneEffectiveEnabled(zone) {
function renderControlPlan() { function renderControlPlan() {
const host = $('#controlPlan'); if (!host) return; const host = $('#controlPlan'); if (!host) return;
const section = $('#controlPlanSection');
const plan = app.controlPlan; const plan = app.controlPlan;
if (!plan) { if (!plan) {
if (section) section.hidden = false;
host.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`; host.innerHTML = `<div class="panel plan-loading">${esc(tr('plan.loading'))}</div>`;
return; return;
} }
const allZones = plan.zones || []; const allZones = plan.zones || [];
const houseEvents = (plan.next_events || []).slice(0, 3); const houseEvents = (plan.next_events || []).slice(0, 3);
const house = `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}</p><ul class="plan-events">${houseEvents.length ? houseEvents.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`; const house = houseEvents.length ? `<article class="panel plan-card plan-house"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.house'))}</span><h3>${esc(houseModeLabel(plan.house_mode || 'off'))}</h3></div><span class="badge active">${esc((plan.control_strategy || 'setpoint') === 'setpoint' ? tr('plan.strategySetpoint') : (plan.control_strategy || 'setpoint'))}</span></div><p>${esc(tr('plan.houseSummary', { zones: allZones.filter(planZoneEffectiveEnabled).length, demand: allZones.filter(zone => planZoneEffectiveEnabled(zone) && zone.demand).length }))}</p><ul class="plan-events">${houseEvents.map(planEventMarkup).join('')}</ul></article>` : '';
const groupCards = (app.groups || []).map(group => { const groupCards = (app.groups || []).map(group => {
const memberIds = new Set(group.zone_ids || []); const memberIds = new Set(group.zone_ids || []);
@@ -98,7 +100,8 @@ function renderControlPlan() {
const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset); const preset = state.preset === 'mixed' ? tr('groups.mixed') : zonePresetLabel(state.preset);
const events = members.flatMap(zone => (zone.next_events || []).map(event => ({ ...event, label: `${zone.zone_name}: ${event.label}` }))) const events = members.flatMap(zone => (zone.next_events || []).map(event => ({ ...event, label: `${zone.zone_name}: ${event.label}` })))
.sort((a, b) => new Date(a.at) - new Date(b.at)).slice(0, 2); .sort((a, b) => new Date(a.at) - new Date(b.at)).slice(0, 2);
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', { zones: members.length, demand }))}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr(powerEnabled ? 'plan.noEvents' : 'plan.groupOff'))}</li>`}</ul></article>`; if (!events.length) return '';
return `<article class="panel plan-card plan-group ${powerEnabled ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div><div class="plan-group-state"><strong>${esc(mode)}</strong><span>·</span><strong>${esc(preset)}</strong></div><p>${esc(tr('plan.groupSummary', { zones: members.length, demand }))}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
}).join(''); }).join('');
const zoneGroups = new Map(); const zoneGroups = new Map();
@@ -109,16 +112,26 @@ function renderControlPlan() {
})); }));
const zones = allZones.map(zone => { const zones = allZones.map(zone => {
const events = (zone.next_events || []).slice(0, 2); const events = (zone.next_events || []).slice(0, 2);
if (!events.length) return '';
const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1); const target = zone.target_temperature == null ? '--' : Number(zone.target_temperature).toFixed(1);
const groupNames = zoneGroups.get(zone.zone_id) || []; const groupNames = zoneGroups.get(zone.zone_id) || [];
const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice')); const scope = groupNames.length ? `${tr('groups.group')}: ${groupNames.join(' · ')}` : (zone.device_name || tr('common.noDevice'));
const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off'); const planState = zoneRuntimeStatusLabel(zone, zone.mode || 'off');
return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.length ? events.map(planEventMarkup).join('') : `<li class="muted">${esc(tr('plan.noEvents'))}</li>`}</ul></article>`; return `<article class="panel plan-card plan-zone ${planZoneEffectiveEnabled(zone) ? '' : 'disabled'}"><div class="plan-card-head"><div><span class="eyebrow">${esc(scope)}</span><h3>${esc(zone.zone_name)}</h3></div><span class="badge ${zone.device_manual_override ? 'manual-override' : (planZoneEffectiveEnabled(zone) && zone.demand ? 'active' : '')}">${esc(planState)}</span></div><div class="plan-temp"><span>${fmtTemp(zone.current_temperature)}</span><b>→</b><strong>${esc(target)}<small>°C</small></strong></div><p>${esc(houseModeLabel(zone.mode || 'off'))} · ${esc(zonePresetLabel(zone.preset))}${zone.current_schedule_name ? ` · ${esc(zone.current_schedule_name)}` : ''}</p><ul class="plan-events">${events.map(planEventMarkup).join('')}</ul></article>`;
}).join(''); }).join('');
const rules = (plan.rules || []).filter(rule => rule.enabled); const rules = (plan.rules || []).filter(rule => rule.enabled);
const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => `<li><time>${esc(automationTriggerLabel(rule))}</time><span>${esc(rule.name)}${esc(rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || ''))}</span></li>`).join('')}</ul></article>` : ''; const ruleCard = rules.length ? `<article class="panel plan-card plan-rules"><div class="plan-card-head"><div><span class="eyebrow">${esc(tr('plan.rules'))}</span><h3>${esc(tr('plan.ruleCount', { count: rules.length }))}</h3></div></div><ul class="plan-events">${rules.slice(0, 3).map(rule => {
host.innerHTML = house + groupCards + zones + ruleCard; const automation = (app.automations || []).find(item => item.id === rule.id);
const flow = automation?.flow_id ? (app.flows || []).find(item => item.id === automation.flow_id) : null;
const displayName = flow?.name || rule.name;
const target = rule.action_group_name ? `${tr('groups.group')}: ${rule.action_group_name}` : (rule.action_device_name || '');
return `<li><time>${esc(automationTriggerLabel(rule))}</time><span class="plan-rule-summary"><strong>${esc(displayName)}</strong>${target ? `<small>→ ${esc(target)}</small>` : ''}</span></li>`;
}).join('')}</ul></article>` : '';
const content = house + groupCards + zones + ruleCard;
host.innerHTML = content;
if (section) section.hidden = !content;
} }
async function loadControlPlan() { async function loadControlPlan() {
try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); } try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); }
+1 -1
View File
@@ -477,7 +477,7 @@ function renderFlowPresetPreview(preset) {
<div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}</span></div> <div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}</span></div>
${flowPresetPreviewGraph(preset)} ${flowPresetPreviewGraph(preset)}
<div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}</p>` : ''}</div> <div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}</p>` : ''}</div>
<div class="dialog-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`; <div class="form-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`;
} }
function flowPresetCategoryLabel(key) { return tr(`flow.templateCategory.${key}`) === `flow.templateCategory.${key}` ? key : tr(`flow.templateCategory.${key}`); } function flowPresetCategoryLabel(key) { return tr(`flow.templateCategory.${key}`) === `flow.templateCategory.${key}` ? key : tr(`flow.templateCategory.${key}`); }
+47 -9
View File
@@ -1195,6 +1195,10 @@ dialog::backdrop {
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
.dialog-wide {
width: min(860px, calc(100% - 24px));
}
.dialog-form { .dialog-form {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -4869,6 +4873,45 @@ body.simulation-standalone [data-view="simulation"] {
white-space: nowrap; white-space: nowrap;
} }
#controlPlan .plan-rules {
gap: 10px;
}
#controlPlan .plan-rules .plan-card-head {
padding-bottom: 2px;
}
#controlPlan .plan-rules .plan-events {
gap: 8px;
padding-top: 9px;
}
#controlPlan .plan-rules .plan-events li {
align-items: start;
gap: 8px;
}
#controlPlan .plan-events .plan-rule-summary {
display: grid;
gap: 2px;
overflow: visible;
text-overflow: clip;
white-space: normal;
}
#controlPlan .plan-rule-summary strong {
color: var(--text);
font-size: 10px;
font-weight: 650;
line-height: 1.35;
}
#controlPlan .plan-rule-summary small {
color: var(--muted);
font-size: 9px;
line-height: 1.35;
}
#controlPlan .plan-events .muted { #controlPlan .plan-events .muted {
white-space: normal; white-space: normal;
} }
@@ -6194,9 +6237,6 @@ body.flow-editor-open { overflow:hidden; }
} }
/* Flow diagnostics, templates and portable Flow files */ /* Flow diagnostics, templates and portable Flow files */
.flow-tool-dialog { width:min(860px,calc(100vw - 32px)); max-height:min(820px,calc(100vh - 32px)); overflow:auto; border:1px solid var(--line); border-radius:16px; background:var(--surface); color:var(--text); padding:18px; box-shadow:0 24px 70px rgba(0,0,0,.32); }
.flow-tool-dialog::backdrop { background:rgba(0,0,0,.48); }
.flow-template-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; margin-top:16px; }
.flow-template-card { display:grid; gap:7px; text-align:left; align-content:start; min-height:118px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text); padding:14px; } .flow-template-card { display:grid; gap:7px; text-align:left; align-content:start; min-height:118px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text); padding:14px; }
.flow-template-card:hover { border-color:var(--accent); } .flow-template-card:hover { border-color:var(--accent); }
.flow-template-card span { color:var(--muted); font-size:.86rem; line-height:1.4; } .flow-template-card span { color:var(--muted); font-size:.86rem; line-height:1.4; }
@@ -6246,8 +6286,6 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-input-fields { display:grid; gap:12px; } .flow-shared-input-fields { display:grid; gap:12px; }
.flow-shared-input-fields .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; } .flow-shared-input-fields .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.flow-template-list { display:grid; grid-template-columns:1fr; gap:22px; } .flow-template-list { display:grid; grid-template-columns:1fr; gap:22px; }
.flow-template-category { display:grid; gap:10px; }
.flow-template-category + .flow-template-category { padding-top:18px; border-top:1px solid var(--line); }
.flow-template-category-head { display:grid; gap:3px; } .flow-template-category-head { display:grid; gap:3px; }
.flow-template-category-head span { color:var(--muted); font-size:.84rem; } .flow-template-category-head span { color:var(--muted); font-size:.84rem; }
.flow-template-category-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; } .flow-template-category-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; }
@@ -6263,8 +6301,6 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-settings-head { justify-content:space-between; align-items:flex-start; } .flow-shared-settings-head { justify-content:space-between; align-items:flex-start; }
.flow-shared-settings-head > div { min-width:0; } .flow-shared-settings-head > div { min-width:0; }
.flow-shared-add-button { flex:0 0 auto; align-self:flex-start; margin-left:auto; } .flow-shared-add-button { flex:0 0 auto; align-self:flex-start; margin-left:auto; }
.flow-shared-dialog-actions { display:flex; align-items:center; justify-content:space-between; gap:24px; padding-top:14px; margin-top:4px; border-top:1px solid var(--line); }
.flow-shared-dialog-actions > button { min-width:112px; }
.flow-shared-settings-link { margin-top:12px; width:max-content; max-width:100%; } .flow-shared-settings-link { margin-top:12px; width:max-content; max-width:100%; }
.flow-shared-settings.is-linked-target { outline:2px solid var(--accent); outline-offset:3px; } .flow-shared-settings.is-linked-target { outline:2px solid var(--accent); outline-offset:3px; }
.flow-template-tabs { display:flex; gap:8px; overflow-x:auto; padding:4px 0 10px; margin-top:14px; border-bottom:1px solid var(--line); scrollbar-width:thin; } .flow-template-tabs { display:flex; gap:8px; overflow-x:auto; padding:4px 0 10px; margin-top:14px; border-bottom:1px solid var(--line); scrollbar-width:thin; }
@@ -6276,7 +6312,6 @@ body.flow-editor-open { overflow:hidden; }
@media (max-width:760px) { @media (max-width:760px) {
.flow-shared-settings-head { gap:12px; } .flow-shared-settings-head { gap:12px; }
.flow-shared-add-button { width:auto; } .flow-shared-add-button { width:auto; }
.flow-shared-dialog-actions { gap:16px; }
} }
/* v0.9.10: Flow preset library preview, favorites/search and shared-input diagnostics */ /* v0.9.10: Flow preset library preview, favorites/search and shared-input diagnostics */
@@ -6328,7 +6363,7 @@ body.flow-editor-open { overflow:hidden; }
.flow-template-favorite { width:34px; height:34px; border:0; border-radius:50%; background:transparent; color:var(--muted); cursor:pointer; font-size:1.15rem; line-height:1; } .flow-template-favorite { width:34px; height:34px; border:0; border-radius:50%; background:transparent; color:var(--muted); cursor:pointer; font-size:1.15rem; line-height:1; }
.flow-template-card > .flow-template-favorite { position:absolute; right:7px; top:7px; } .flow-template-card > .flow-template-favorite { position:absolute; right:7px; top:7px; }
.flow-template-favorite.active { color:var(--accent); background:var(--accent-soft); } .flow-template-favorite.active { color:var(--accent); background:var(--accent-soft); }
.flow-template-preview .dialog-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); justify-content:flex-end; } .flow-template-preview .form-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); }
@media (max-width:900px) { @media (max-width:900px) {
.flow-template-browser { grid-template-columns:1fr; } .flow-template-browser { grid-template-columns:1fr; }
@@ -6341,3 +6376,6 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-test-result dl > div { grid-template-columns:1fr; gap:2px; } .flow-shared-test-result dl > div { grid-template-columns:1fr; gap:2px; }
.flow-template-preview-canvas { height:220px; } .flow-template-preview-canvas { height:220px; }
} }
/* Keep zone hysteresis/source controls aligned without stretching selects. */
.zone-hysteresis-row { align-items:start; }