This commit is contained in:
Mateusz Gruszczyński
2026-09-03 09:12:27 +02:00
parent 93d93eca91
commit 14782c86aa
21 changed files with 8793 additions and 82 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.11.3"
version = "0.11.4"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.11.3"
version = "0.11.4"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+20 -16
View File
@@ -1,11 +1,11 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
2b03135cfb84c2df0f14380231e954882d743ba922c65ba3cbc56474057a039b ./Cargo.lock
2bd686728663c50fa901cc3b35519da0e91e08e44994aff46783dedbd62409bd ./Cargo.toml
ee9f0a05e85c3ffc081647716020ddfa26dfe128bc955eaed56a3b57cbbb81e6 ./Cargo.lock
2cb81c4e01143414768f0592c4e362056ecf04678c465503789b6162fefa700c ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
fe279b20b6e9da85c1e639078c213875bfa842102017cda4d0d06120fe8fe3ba ./README.md
a2713e3390c232ccf94e310ba18cb18ac47156ec050260f697fbc4257a9e1239 ./README.md
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
ec33deef383b0aee070d3fef68c54387d6dba4fc2fc9b56f4a902484c5338989 ./docs/API.md
14188e547cd09ec63240cee480cf499ca479f300653fdf24dbef90a7175e4ec5 ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
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
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
629c10dd8b65cfde3a1db168cd6b5b642a87c4a4cd1149cc77121a121e525bbb ./home-assistant/custom_components/gree_controller/manifest.json
1168f40e21d3c21341353f254f0b79157133e9625f9cdcd552bf6bcfd444e7c9 ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
@@ -26,9 +26,13 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./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
69f9a7d4152036db8a48773d5c0fa364ab0fbb9cde1f28d07390023509d3544d ./lang/en.json
36cbb4ab3233b21ef13ce1dd01f747c5067d406f246df8c52ea48b3a426d64f3 ./lang/pl.json
7414e94d61d2dfd9734ee92a2a1423cae67e91e8733c213098b9bcb6a2885a22 ./lang/en.json
1ff6dab25d8a29d971cd10de8d0df1b6173670ba1e6f65c0c669bf6b134bcf23 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
1e7b1bcf4194abfb8bad0d601268af0547d58541731e98b74a3500d7629d0be0 ./mock/index.html
007c78e0cb404778985980b23bb5f8bbc7a8f04c80015204e24c321970e3b15e ./mock/mock.js
75999d965ea56a2cd961afe5771e6e9b1be513fab1dcafb480fbe9c0c232e7df ./mock/styles.css
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./mock/theme-init.js
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
c5276fecc6a33c5de912e5a73b0a829d4e18c18d57b9c0ee49179ef96f133fc7 ./presets/dual_threshold_control.json
@@ -172,24 +176,24 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
b39ebf9538c078f2311d3064207da0fa46217037e1eb56566867028565719e53 ./web/index.html
4f273e189008df110cd3cad1d323e5f07e2651f805419b427811d89fdd04245f ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
782ce96f3ec006c447e8c44c60ce4142e0fd830704a6ef0511a66505c12cb6bf ./web/js/core.js
dee7c4292889ddd721c220e86fae909f0d662b585e6d9c5d9e2f8fa85d9ed2ad ./web/js/charts.js
87064e7d504dc2afbc1aa3a1926fcec0cc80b31e2a6a6d931d7af7fc4f519b20 ./web/js/core.js
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
2bcf1292d26815e4919492c558601d8f0ddd61163f7ec916b562a40b409abdde ./web/js/flows.js
3f5b9a4d62c994edded0ce24fb9ea45141d935a1928d4f68fb67f8ca287bafe1 ./web/js/entities.js
cdd11ab0fac33b7b1749b8db4e30486e9d0258b65fe54fd56c3a520b2b72b2d7 ./web/js/events.js
d4aea1d6ec595000eaddca3b6f0e3d4003901365ece7788147a6ed207cffcc78 ./web/js/flows.js
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js
3dcfc24f238ad1310155707268b60c5a415f2a8977ceba48c7e695b7135d5705 ./web/js/navigation.js
c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js
bdeb55546a1dff5e5bdfde4b114d6330f025df1b62e528bf18a93ad6dd4c3e83 ./web/js/settings-ui.js
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
3069a9860857d4c9f1d820c6066cc9c9326d495b49083a422d327249032dd9e4 ./web/styles.css
75999d965ea56a2cd961afe5771e6e9b1be513fab1dcafb480fbe9c0c232e7df ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.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.
**Current release: 0.11.3**
**Current release: 0.11.4**
> [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
HTTP and WebSocket API for GREE Controller **0.11.3**.
HTTP and WebSocket API for GREE Controller **0.11.4**.
[← Main documentation](../README.md)
@@ -213,7 +213,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.11.3",
"version": "0.11.4",
"uptime_seconds": 1234,
"control_ready": true,
"time": "2026-08-30T06:54:00Z"
@@ -237,7 +237,7 @@ Returns the initial Web UI snapshot:
"settings": {},
"outdoor_temperature": null,
"system": {
"version": "0.11.3",
"version": "0.11.4",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.11.1",
"version": "0.11.4",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
+31 -3
View File
@@ -474,7 +474,7 @@
"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 only in charts and selectors. Home Assistant requests always use the original entity_id.",
"settings.sensorAliasesHint": "Friendly names are used in charts and selectors. Badges show whether an entity is collected as metrics, used by Flow, or both. A shared Flow input by itself does not enable metric collection.",
"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.",
@@ -730,6 +730,8 @@
"history.zoomOut": "Zoom out",
"history.zoomReset": "Reset zoom",
"history.zoomIn": "Zoom in",
"history.fullscreen": "Enlarge chart",
"history.exitFullscreen": "Close enlarged chart",
"house.powerOffConfirm": "Turn all air conditioners off? All thermostats will remain locally OFF with no timer. You can later enable an individual thermostat, a group, or the whole house.",
"zones.temporaryStarting": "Starting…",
"notifications.applicationTokenPlaceholder": "Application token",
@@ -943,7 +945,7 @@
"flow.sharedInputType": "Source type",
"flow.sharedInputAdd": "Add input",
"flow.sharedInputsTitle": "Shared Flow inputs",
"flow.sharedInputsHint": "Define reusable value sources once, then use them in many Flows. Operators and comparison values are configured only in Flow blocks.",
"flow.sharedInputsHint": "Define reusable value sources once, then use them in many Flows. Operators and comparison values are configured only in Flow blocks. HA sensors added only here are not stored as metrics.",
"flow.sharedInputsEmpty": "No shared inputs",
"flow.sharedInputsEmptyHint": "Add a constant, Home Assistant state, or another shared source for multiple Flows.",
"flow.sharedInputNodeHint": "This block uses a central value source. The comparison below belongs only to this Flow.",
@@ -1056,6 +1058,12 @@
"flow.durationSeconds": "Duration [s]",
"flow.stableForHint": "The input condition must remain true continuously for the configured duration. State survives restarts.",
"flow.serviceData": "Service data (JSON)",
"flow.serviceExample": "Example: to turn on switch.gas use domain = switch, service = turn_on and entity_id = switch.gas.",
"flow.enabledState": "Enabled",
"flow.disabledState": "Disabled",
"flow.enabledSelect": "Flow state",
"settings.sensorMetricBadge": "Metrics",
"settings.sensorFlowBadge": "Flow",
"flow.invalidJson": "Invalid JSON",
"flow.mean": "Mean",
"flow.median": "Median",
@@ -1101,6 +1109,26 @@
"flow.draftDisabledHint": "A draft is always disabled. Finish the Flow and save it again to create executable tasks.",
"flow.draftCannotEnable": "Finish and save the Flow first. A draft cannot be enabled.",
"flow.saveAsDraftConfirm": "The Flow cannot be saved as executable:\n\n{reason}\n\nSave it as a draft instead? The draft will be disabled and will not create any tasks or schedules.",
"flow.savedAsDraft": "Flow saved as a disabled draft. You can return to it later."
"flow.savedAsDraft": "Flow saved as a disabled draft. You can return to it later.",
"automationHub.navigation": "Automation section",
"nav.sectionControl": "Control",
"nav.sectionAutomation": "Automation",
"nav.sectionData": "Data & system",
"nav.homeAssistantShort": "Home Assistant",
"actions.more": "More options",
"flow.addBlock": "Add block",
"flow.searchBlocks": "Search blocks…",
"flow.noBlocksFound": "No matching blocks",
"flow.noBlocksFoundHint": "Change the search phrase or clear the filter.",
"flow.zoomControls": "Flow canvas zoom",
"flow.zoomOut": "Zoom out",
"flow.zoomIn": "Zoom in",
"flow.fitView": "Fit",
"flow.unsavedChanges": "Unsaved changes",
"flow.savedState": "Saved",
"flow.expandSettings": "Expand block settings",
"flow.notSavedYet": "Not saved yet",
"schedules.enabledState": "Enabled",
"schedules.disabledState": "Disabled"
}
}
+33 -5
View File
@@ -474,7 +474,7 @@
"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 tylko na wykresach i w selektorach. Zapytania do Home Assistanta zawsze używają oryginalnego entity_id.",
"settings.sensorAliasesHint": "Przyjazne nazwy są używane na wykresach i w selektorach. Oznaczenia pokazują, czy encja trafia do metryk, jest używana we Flow, czy w obu miejscach. Samo wspólne wejście Flow nie uruchamia zapisu metryk.",
"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.",
@@ -730,6 +730,8 @@
"history.zoomOut": "Pomniejsz wykres",
"history.zoomReset": "Przywróć powiększenie",
"history.zoomIn": "Powiększ wykres",
"history.fullscreen": "Powiększ wykres",
"history.exitFullscreen": "Zamknij powiększony wykres",
"house.powerOffConfirm": "Wyłączyć wszystkie klimatyzatory? Wszystkie termostaty pozostaną lokalnie WYŁ. bez timera. Później możesz włączyć pojedynczy termostat, grupę albo cały dom.",
"zones.temporaryStarting": "Uruchamianie…",
"notifications.applicationTokenPlaceholder": "Token aplikacji",
@@ -814,7 +816,7 @@
"zones.queueCancelAllConfirm": "Anulować wszystkie oczekujące zadania ({count}) ochrony sprężarki?",
"nav.flows": "Flow",
"flow.eyebrow": "Logika wizualna",
"flow.new": "Nowy Flow",
"flow.new": "Nowe flow",
"flow.infoTitle": "Jedno miejsce dla harmonogramów i automatyzacji",
"flow.infoText": "Układasz logikę z bloków. GREE Controller tłumaczy Flow na generowane harmonogramy i automatyzacje. Wygenerowane wpisy są tylko do odczytu poza tym edytorem.",
"flow.sourceOfTruth": "Flow = źródło logiki",
@@ -932,7 +934,7 @@
"flow.listCount": "{count} Flow",
"flow.quickToggleHint": "Szybko włącz lub wyłącz Flow bez otwierania edytora",
"flow.quickEnabled": "Flow włączony",
"flow.quickDisabled": "Flow wyłączony",
"flow.quickDisabled": "Flow wyłączone",
"flow.selectAll": "Zaznacz wszystko",
"flow.clearSelection": "Wyczyść zaznaczenie",
"flow.selectedCount": "Zaznaczono: {count}",
@@ -943,7 +945,7 @@
"flow.sharedInputType": "Typ źródła",
"flow.sharedInputAdd": "Dodaj wejście",
"flow.sharedInputsTitle": "Wspólne wejścia Flow",
"flow.sharedInputsHint": "Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. Operatory i wartości porównań ustawia się wyłącznie w blokach Flow.",
"flow.sharedInputsHint": "Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. Operatory i wartości porównań ustawia się wyłącznie w blokach Flow. Czujniki HA dodane tylko tutaj nie są zapisywane do metryk.",
"flow.sharedInputsEmpty": "Brak wspólnych wejść",
"flow.sharedInputsEmptyHint": "Dodaj stałą, stan Home Assistant albo inne współdzielone źródło dla wielu Flow.",
"flow.sharedInputNodeHint": "Ten blok korzysta z centralnego źródła wartości. Poniższe porównanie należy tylko do tego Flow.",
@@ -1056,6 +1058,12 @@
"flow.durationSeconds": "Czas [s]",
"flow.stableForHint": "Warunek na wejściu musi pozostać prawdziwy bez przerwy przez podany czas. Stan jest zachowywany po restarcie.",
"flow.serviceData": "Dane usługi (JSON)",
"flow.serviceExample": "Przykład: aby włączyć switch.gas ustaw domain = switch, service = turn_on i entity_id = switch.gas.",
"flow.enabledState": "Włączone",
"flow.disabledState": "Wyłączone",
"flow.enabledSelect": "Stan Flow",
"settings.sensorMetricBadge": "Metryki",
"settings.sensorFlowBadge": "Flow",
"flow.invalidJson": "Nieprawidłowy JSON",
"flow.mean": "Średnia",
"flow.median": "Mediana",
@@ -1101,6 +1109,26 @@
"flow.draftDisabledHint": "Szkic jest zawsze wyłączony. Dokończ Flow i zapisz go ponownie, aby utworzyć zadania wykonawcze.",
"flow.draftCannotEnable": "Najpierw dokończ i zapisz Flow. Szkicu nie można włączyć.",
"flow.saveAsDraftConfirm": "Flow nie może zostać zapisany jako wykonywalny:\n\n{reason}\n\nZapisać go jako szkic? Szkic będzie wyłączony i nie utworzy żadnych zadań ani harmonogramów.",
"flow.savedAsDraft": "Flow zapisany jako wyłączony szkic. Możesz wrócić do niego później."
"flow.savedAsDraft": "Flow zapisany jako wyłączony szkic. Możesz wrócić do niego później.",
"automationHub.navigation": "Sekcja automatyzacji",
"nav.sectionControl": "Sterowanie",
"nav.sectionAutomation": "Automatyzacja",
"nav.sectionData": "Dane i system",
"nav.homeAssistantShort": "Home Assistant",
"actions.more": "Więcej opcji",
"flow.addBlock": "Dodaj blok",
"flow.searchBlocks": "Szukaj bloków…",
"flow.noBlocksFound": "Brak pasujących bloków",
"flow.noBlocksFoundHint": "Zmień wyszukiwaną frazę lub wyczyść filtr.",
"flow.zoomControls": "Powiększenie obszaru Flow",
"flow.zoomOut": "Pomniejsz",
"flow.zoomIn": "Powiększ",
"flow.fitView": "Dopasuj",
"flow.unsavedChanges": "Niezapisane zmiany",
"flow.savedState": "Zapisano",
"flow.expandSettings": "Rozwiń ustawienia bloku",
"flow.notSavedYet": "Jeszcze nie zapisano",
"schedules.enabledState": "Włączony",
"schedules.disabledState": "Wyłączony"
}
}
+1302
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
document.addEventListener('DOMContentLoaded',()=>{
const q=new URLSearchParams(location.search);
document.documentElement.dataset.theme='dark';
const view=q.get('view')||'dashboard';
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view===view));
if(view==='dashboard'){
document.querySelector('#summaryText').textContent='3 urządzenia online · 2 aktywne termostaty';
document.querySelector('#metrics').innerHTML='<span>Online <b>3/3</b></span><span>Aktywne <b>2</b></span>';
document.querySelector('#heroTemperature').innerHTML='21.4<small>°C</small>';
document.querySelector('#houseClimate').innerHTML='<div class="house-climate-head"><div><h3>Tryb domu</h3><p>Wspólne ustawienia dla stref.</p></div><span class="outside-pill">Na zewnątrz 12.8°C</span></div><div class="house-mode-row"><button class="active">Grzanie</button><button>Chłodzenie</button></div><div class="house-preset-row"><button>Auto</button><button class="active">Komfort</button><button>Sen</button><button>Poza domem</button></div>';
document.querySelector('#controlPlan').innerHTML='<article class="list-card"><div class="list-card-head"><div><h3>Salon</h3><p>Utrzymuj 21.5°C do 22:30</p></div><span class="badge">Aktywne</span></div></article><article class="list-card"><div class="list-card-head"><div><h3>Sypialnia</h3><p>Tryb nocny od 22:30</p></div><span class="badge">za 2h</span></div></article>';
}
if(view==='flows'){
document.querySelector('#flowListCount').textContent='3 flow';
document.querySelector('#flowList').innerHTML=`<article class="list-card flow-card"><div class="list-card-head"><div><h3>Okno w salonie</h3><p>Wyłącza grzanie, gdy okno jest otwarte.</p></div><button class="zone-enable-toggle active"><span>✓</span>Włączone</button></div><div class="card-stats"><div class="card-stat"><small>Bloki</small><strong>5</strong></div><div class="card-stat"><small>Harmonogramy</small><strong>0</strong></div><div class="card-stat"><small>Automatyzacje</small><strong>1</strong></div></div><div class="card-footer"><small>1 automatyzacja</small><div class="card-menu"><button>Otwórz edytor</button><button class="danger">Usuń</button></div></div></article><article class="list-card flow-card is-disabled"><div class="list-card-head"><div><h3>Tani prąd</h3><p>Podbija temperaturę przy niskiej cenie energii.</p></div><button class="zone-enable-toggle"><span>○</span>Wyłączone</button></div><div class="card-stats"><div class="card-stat"><small>Bloki</small><strong>7</strong></div><div class="card-stat"><small>Harmonogramy</small><strong>1</strong></div><div class="card-stat"><small>Automatyzacje</small><strong>2</strong></div></div><div class="card-footer"><small>1 harmonogram · 2 automatyzacje</small><div class="card-menu"><button>Otwórz edytor</button><button class="danger">Usuń</button></div></div></article>`;
}
if(q.get('editor')==='1'){
document.querySelector('#flowEditor').hidden=false; document.body.classList.add('flow-editor-open');
document.querySelector('#flowName').hidden=true; document.querySelector('#flowNameView').hidden=false; document.querySelector('#flowNameText').textContent='Okno w salonie';
document.querySelector('#flowCompileStatus').textContent='0 harm. · 1 autom.'; document.querySelector('#flowSaveStatus').textContent='Zapisano';
document.querySelector('#flowInterpretation').textContent='Jeśli okno w salonie jest otwarte przez 30 s, wyłącz termostat Salon.';
document.querySelector('#flowRuntimeSummary').textContent='Ostatnie wykonanie: 07:12 · wynik: prawda';
document.querySelector('#flowEmptyHint').style.display='none';
document.querySelector('#flowNodes').innerHTML=`<div class="flow-node flow-node-sensor selected" style="left:60px;top:80px"><div class="flow-node-head">Wspólne wejście <button>×</button></div><div class="flow-node-body">Okno w salonie<br><div class="flow-node-current"><span>Teraz</span><strong>otwarte</strong></div></div><button class="flow-port flow-port-out"></button></div><div class="flow-node flow-node-timeop" style="left:300px;top:160px"><button class="flow-port flow-port-in"></button><div class="flow-node-head">Utrzymuje się przez… <button>×</button></div><div class="flow-node-body">30 sekund</div><button class="flow-port flow-port-out"></button></div><div class="flow-node flow-node-action" style="left:550px;top:95px"><button class="flow-port flow-port-in"></button><div class="flow-node-head">Termostat <button>×</button></div><div class="flow-node-body">Salon · wyłącz</div></div>`;
document.querySelector('#flowInspector').classList.add('has-selection');
document.querySelector('#flowInspector').innerHTML='<div class="flow-inspector-head"><div><span class="eyebrow">Ustawienia bloku</span><h3>Wspólne wejście</h3></div><div class="flow-inspector-head-actions"><button class="flow-inspector-close">×</button></div></div><label><span>Źródło</span><select><option>Okno w salonie</option></select></label><p class="field-note">Czujnik używany tylko we Flow. Nie jest zapisywany do metryk.</p>';
}
});
+6763
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
'use strict';
(() => {
const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_theme='));
const requested = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'system';
const resolved = requested === 'light' || requested === 'dark'
? requested
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = resolved;
})();
+64 -13
View File
@@ -182,6 +182,11 @@
<h1 data-i18n="nav.flows">Flow</h1>
</div><div class="section-actions flow-page-actions"><button class="secondary" type="button" data-action="import-flow" data-i18n="flow.import">Import Flow</button><button class="primary" type="button" data-action="new-flow" data-i18n="flow.new">New Flow</button></div>
</div>
<nav class="automation-tabs" data-i18n-aria="automationHub.navigation" aria-label="Automation navigation">
<button type="button" class="active" data-go="flows" data-i18n="nav.flows">Flow</button>
<button type="button" data-go="schedules" data-i18n="nav.schedules">Schedules</button>
<button type="button" data-go="automations" data-i18n="nav.automations">Automations</button>
</nav>
<div class="panel flow-info-panel">
<div><strong data-i18n="flow.infoTitle">One place for schedules and automations</strong>
<p data-i18n="flow.infoText">Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.</p>
@@ -200,6 +205,11 @@
<h1 data-i18n="nav.schedules">Schedules</h1>
</div><button class="secondary" data-open="scheduleDialog" data-i18n="actions.add">Add</button>
</div>
<nav class="automation-tabs" data-i18n-aria="automationHub.navigation" aria-label="Automation navigation">
<button type="button" data-go="flows" data-i18n="nav.flows">Flow</button>
<button type="button" class="active" data-go="schedules" data-i18n="nav.schedules">Schedules</button>
<button type="button" data-go="automations" data-i18n="nav.automations">Automations</button>
</nav>
<div class="panel schedule-template-panel">
<div>
<h3 data-i18n="schedules.quickSetup">Quick schedule setup</h3>
@@ -228,6 +238,11 @@
<h1 data-i18n="nav.automations">Automations</h1>
</div><button class="secondary" data-open="automationDialog" data-i18n="actions.add">Add</button>
</div>
<nav class="automation-tabs" data-i18n-aria="automationHub.navigation" aria-label="Automation navigation">
<button type="button" data-go="flows" data-i18n="nav.flows">Flow</button>
<button type="button" data-go="schedules" data-i18n="nav.schedules">Schedules</button>
<button type="button" class="active" data-go="automations" data-i18n="nav.automations">Automations</button>
</nav>
<div class="panel automation-hint"><strong data-i18n="automations.smartHintTitle">Use thermostat presets for daily
comfort</strong>
<p data-i18n="automations.smartHint">Schedules and zone presets handle normal heating/cooling automatically.
@@ -396,12 +411,12 @@
</section>
<section class="panel settings-block flow-shared-settings" id="flowSharedInputsSettings">
<div class="settings-block-head flow-shared-settings-head"><div><h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3><p data-i18n="flow.sharedInputsHint">Zdefiniuj wartości, stany i czujniki raz, a potem używaj ich w wielu Flow. Operator jest opcjonalny.</p></div><button type="button" class="primary flow-shared-add-button" id="addFlowSharedInput" data-i18n="flow.sharedInputAdd">Dodaj wejście</button></div>
<div class="settings-block-head flow-shared-settings-head"><div><h3 data-i18n="flow.sharedInputsTitle">Wspólne wejścia Flow</h3><p data-i18n="flow.sharedInputsHint">Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. Czujniki HA dodane tylko tutaj nie są zapisywane do metryk.</p></div><button type="button" class="primary flow-shared-add-button" id="addFlowSharedInput" data-i18n="flow.sharedInputAdd">Dodaj wejście</button></div>
<input type="hidden" id="flowSharedInputsRevision" name="flow_inputs_revision" value="[]"><div id="flowSharedInputList" class="flow-shared-input-list"></div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.sensorAliases">Sensor aliases</h3><p data-i18n="settings.sensorAliasesHint">Friendly names are used in charts and selectors; the original entity_id remains unchanged.</p></div></div>
<div class="settings-block-head"><div><h3 data-i18n="settings.sensorAliases">Sensor aliases</h3><p data-i18n="settings.sensorAliasesHint">Badges show whether an entity is collected as metrics, used by Flow, or both.</p></div></div>
<div class="sensor-alias-manager">
<div id="sensorAliasList" class="sensor-alias-list"></div>
<div class="sensor-alias-add"><input id="sensorAliasEntity" placeholder="sensor.gabinet_temperature"><input id="sensorAliasName" data-i18n-placeholder="settings.aliasPlaceholder" placeholder="Gabinet"><button type="button" class="secondary" id="addSensorAlias" data-i18n="actions.add">Add</button></div>
@@ -694,7 +709,8 @@
<section id="flowEditor" class="flow-editor" hidden aria-label="Flow editor">
<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><div class="flow-name-wrap"><div id="flowNameView" class="flow-name-view" hidden><button id="flowNameText" class="flow-name-text" type="button" data-action="edit-flow-name" data-i18n-title="flow.editName" title="Edit Flow name"></button><button class="flow-name-edit" type="button" data-action="edit-flow-name" data-i18n-title="flow.editName" data-i18n-aria="flow.editName" title="Edit Flow name" aria-label="Edit Flow name"></button></div><input id="flowName" maxlength="100" data-i18n-placeholder="flow.namePlaceholder" data-i18n-title="flow.nameAliasHint" placeholder="My automation"></div></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 flow-desktop-action" type="button" data-action="flow-templates" data-i18n="flow.templates">Templates</button><button class="secondary flow-desktop-action" type="button" data-action="import-flow" data-i18n="flow.import">Import</button><button class="secondary flow-desktop-action" type="button" data-action="export-flow" data-i18n="flow.export">Export</button><button class="secondary flow-desktop-action" type="button" data-action="flow-dry-run" data-i18n="flow.dryRun">Dry-run</button><button class="secondary flow-desktop-action" type="button" data-action="flow-logs" data-i18n="flow.logs">Logs</button><label class="flow-enabled"><input type="checkbox" id="flowEnabled" checked><span id="flowEnabledLabel" data-i18n="common.enabled">Enabled</span></label><span id="flowSaveStatus" class="flow-save-status" aria-live="polite"></span><span id="flowCompileStatus" class="badge flow-compile-status"></span><button class="secondary flow-mobile-actions-button" type="button" data-action="flow-mobile-actions" data-i18n="flow.actions">Actions</button></div>
<button class="primary flow-save-button" type="button" data-action="save-flow" data-i18n="actions.save">Save</button>
</header>
<div class="flow-editor-body">
<aside class="flow-palette">
@@ -714,20 +730,40 @@
<button type="button" data-flow-add="ha_service_action" data-i18n="flow.node.haServiceAction">Home Assistant: usługa</button></div>
</aside>
<div class="flow-workspace-wrap">
<div class="flow-workspace-toolbar"><button type="button" class="secondary" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" class="secondary" data-action="flow-clear-selection" data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount" class="badge"></span></div>
<div class="flow-workspace-toolbar"><div class="flow-workspace-primary-actions"><button type="button" class="primary flow-add-block-button" data-action="flow-add-block"><span aria-hidden="true"></span><span data-i18n="flow.addBlock">Add block</span></button><button type="button" class="secondary flow-selection-action" data-action="flow-select-all" data-i18n="flow.selectAll">Zaznacz wszystko</button><button type="button" class="secondary flow-selection-action" data-action="flow-clear-selection" data-i18n="flow.clearSelection">Wyczyść zaznaczenie</button><span id="flowSelectionCount" class="badge"></span></div><div class="flow-zoom-controls" role="group" data-i18n-aria="flow.zoomControls" aria-label="Canvas zoom"><button type="button" class="secondary icon-button" data-action="flow-zoom-out" data-i18n-title="flow.zoomOut" title="Zoom out"></button><span id="flowZoomLabel" aria-live="polite">100%</span><button type="button" class="secondary icon-button" data-action="flow-zoom-in" data-i18n-title="flow.zoomIn" title="Zoom in"></button><button type="button" class="secondary flow-fit-button" data-action="flow-fit" data-i18n="flow.fitView">Fit</button></div></div>
<div id="flowWorkspace" class="flow-workspace" tabindex="0">
<svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div>
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span></div>
<div id="flowCanvas" class="flow-canvas"><svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div></div>
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span><button type="button" class="primary" data-action="flow-add-block" data-i18n="flow.addBlock">Add block</button></div>
</div>
<div class="flow-natural-preview">
<div class="flow-preview-item flow-preview-interpretation"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
<div class="flow-preview-item flow-preview-runtime" id="flowRuntimeInfo"><strong data-i18n="flow.runtimeInfo">Flow cycle</strong><span id="flowRuntimeSummary"></span></div>
<div class="flow-natural-preview" id="flowNaturalPreview">
<button type="button" class="flow-preview-toggle" data-action="flow-preview-toggle" aria-expanded="false"><strong data-i18n="flow.interpretation">Interpretation</strong><span class="flow-preview-toggle-icon" aria-hidden="true"></span></button>
<div class="flow-preview-details">
<div class="flow-preview-item flow-preview-interpretation"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
<div class="flow-preview-item flow-preview-runtime" id="flowRuntimeInfo"><strong data-i18n="flow.runtimeInfo">Flow cycle</strong><span id="flowRuntimeSummary"></span></div>
</div>
</div>
</div>
<aside id="flowInspector" class="flow-inspector"><div class="empty compact"><strong data-i18n="flow.selectBlock">Select a block</strong><span data-i18n="flow.selectBlockHint">Its settings will appear here.</span></div></aside>
</div>
</section>
<dialog id="flowBlockDialog" class="sheet flow-block-dialog">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.blocks">Blocks</span><h2 data-i18n="flow.addBlock">Add block</h2></div><button type="button" data-close aria-label="Close">×</button></div>
<div class="flow-block-search"><input id="flowBlockSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.searchBlocks" placeholder="Search blocks…"></div>
<div id="flowBlockLibrary" class="flow-block-library"></div>
</dialog>
<dialog id="flowEditorActionsDialog" class="sheet flow-editor-actions-dialog">
<div class="dialog-head"><h2 data-i18n="flow.actions">Actions</h2><button type="button" data-close>×</button></div>
<div class="menu-list">
<button type="button" data-action="flow-templates"><span data-i18n="flow.templates">Templates</span><span></span></button>
<button type="button" data-action="import-flow"><span data-i18n="flow.import">Import</span><span></span></button>
<button type="button" data-action="export-flow"><span data-i18n="flow.export">Export</span><span></span></button>
<button type="button" data-action="flow-dry-run"><span data-i18n="flow.dryRun">Dry-run</span><span></span></button>
<button type="button" data-action="flow-logs"><span data-i18n="flow.logs">Logs</span><span></span></button>
</div>
</dialog>
<input id="flowImportFile" type="file" accept="application/json,.json,.flow.json" hidden>
<dialog id="flowSharedInputDialog">
@@ -775,22 +811,37 @@
</dialog>
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionControl">Sterowanie</span>
<button class="active" data-nav="dashboard"><span></span><b data-i18n="nav.dashboard">Dashboard</b></button>
<button data-nav="devices"><span></span><b data-i18n="nav.devices">Devices</b></button>
<button data-nav="zones"><span></span><b data-i18n="nav.zones">Zones</b></button>
<button class="desktop-nav-only" data-nav="groups"><span></span><b data-i18n="nav.groups">Groups</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionAutomation">Automatyzacja</span>
<button data-nav="flows"><span></span><b data-i18n="nav.flows">Flow</b></button>
<button class="desktop-nav-only" data-nav="schedules"><span></span><b data-i18n="nav.schedules">Schedules</b></button>
<button class="desktop-nav-only" data-nav="automations"><span></span><b data-i18n="nav.automations">Automations</b></button>
<span class="nav-section-label desktop-nav-only" data-i18n="nav.sectionData">Dane i system</span>
<button data-nav="history"><span></span><b data-i18n="nav.history">History</b></button>
<button data-nav="more"><span>•••</span><b data-i18n="nav.more">More</b></button>
<button class="desktop-nav-only" data-nav="devices"><span></span><b data-i18n="nav.devices">Devices</b></button>
<button class="desktop-nav-only" data-nav="homeassistant"><span>HA</span><b data-i18n="nav.homeAssistantShort">Home Assistant</b></button>
<button class="desktop-nav-only" data-nav="simulation"><span></span><b data-i18n="nav.simulation">Simulator</b></button>
<button class="desktop-nav-only" data-nav="night"><span></span><b data-i18n="nav.nightMode">Night mode</b></button>
<button class="desktop-nav-only" data-nav="logs"><span></span><b data-i18n="nav.logs">Events</b></button>
<button class="desktop-nav-only" data-nav="settings"><span></span><b data-i18n="nav.settings">Settings</b></button>
<button class="mobile-nav-only" data-nav="more"><span>•••</span><b data-i18n="nav.more">More</b></button>
</nav>
<dialog id="moreDialog" class="sheet">
<div class="dialog-head">
<h2 data-i18n="nav.more">More</h2><button data-close>×</button>
</div>
<div class="menu-list">
<div class="menu-list more-menu-list">
<span class="menu-section-label" data-i18n="nav.sectionControl">Sterowanie</span>
<button data-go="devices"><span data-i18n="nav.devices">Devices</span><span></span></button>
<button data-go="groups"><span data-i18n="nav.groups">Groups</span><span></span></button>
<button data-go="flows"><span data-i18n="nav.flows">Flow</span><span></span></button>
<span class="menu-section-label" data-i18n="nav.sectionAutomation">Automatyzacja</span>
<button data-go="schedules"><span data-i18n="nav.schedules">Schedules</span><span></span></button>
<button data-go="automations"><span data-i18n="nav.automations">Automations</span><span></span></button>
<span class="menu-section-label" data-i18n="nav.sectionData">Dane i system</span>
<button data-go="simulation"><span data-i18n="nav.simulation">Simulator</span><span></span></button>
<button data-go="night"><span data-i18n="nav.nightMode">Night mode</span><span></span></button>
<button data-go="homeassistant"><span data-i18n="nav.homeAssistant">Home Assistant /
+64 -8
View File
@@ -134,22 +134,71 @@ function timeLabel(ts, hours) {
return new Date(ts).toLocaleString(locale(), options);
}
function chartCardIsFullscreen(card) {
return !!card && card.classList.contains('chart-fullscreen-fallback');
}
function updateChartFullscreenButton(card) {
if (!card) return;
const button = card.querySelector('[data-chart-fullscreen]');
if (!button) return;
const active = chartCardIsFullscreen(card);
button.textContent = active ? '×' : '⛶';
button.title = tr(active ? 'history.exitFullscreen' : 'history.fullscreen');
button.setAttribute('aria-label', button.title);
}
function closeChartPreview(card) {
if (!card) return;
card.classList.remove('chart-fullscreen-fallback');
document.body.classList.remove('chart-fullscreen-open');
updateChartFullscreenButton(card);
const canvas = card.querySelector('canvas[id]');
if (canvas?.id) requestAnimationFrame(() => redrawHistoryChart(canvas.id));
}
function toggleChartFullscreen(id) {
const canvas = document.getElementById(id);
const card = canvas?.closest('.history-chart-card');
if (!card) return;
const active = chartCardIsFullscreen(card);
const opened = document.querySelector('.history-chart-card.chart-fullscreen-fallback');
if (opened && opened !== card) closeChartPreview(opened);
if (active) {
closeChartPreview(card);
return;
}
card.classList.add('chart-fullscreen-fallback');
document.body.classList.add('chart-fullscreen-open');
updateChartFullscreenButton(card);
requestAnimationFrame(() => redrawHistoryChart(id));
}
function prepareCanvas(canvas, height) {
const rect = canvas.getBoundingClientRect();
const wrapWidth = Math.floor(canvas.parentElement?.clientWidth || rect.width || 720);
const wrap = canvas.parentElement;
const wrapWidth = Math.floor(wrap?.clientWidth || rect.width || 720);
const card = canvas.closest('.history-chart-card');
const fullscreenHeight = chartCardIsFullscreen(card) ? Math.floor(wrap?.clientHeight || 0) : 0;
const actualHeight = Math.max(height, fullscreenHeight || 0);
const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, 4);
const width = Math.max(720, Math.floor(Math.max(720, wrapWidth) * zoom));
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr; canvas.height = height * dpr;
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
canvas.width = width * dpr; canvas.height = actualHeight * dpr;
canvas.style.width = `${width}px`; canvas.style.height = `${actualHeight}px`;
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
return { ctx, width, height };
ctx.clearRect(0, 0, width, actualHeight);
return { ctx, width, height:actualHeight };
}
function drawEmptyChart(canvas, height = 340) {
if (!canvas) return;
const { ctx, width } = prepareCanvas(canvas, height);
const prepared = prepareCanvas(canvas, height);
const { ctx, width } = prepared;
height = prepared.height;
ctx.fillStyle = cssColor('--muted', '#888'); ctx.font = '13px system-ui'; ctx.textAlign = 'center';
ctx.fillText(tr('history.noData'), width / 2, height / 2);
const wrap = canvas.parentElement;
@@ -392,7 +441,9 @@ function drawLineChart(canvas, series, rows, { height = 340, minValue = null, ma
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
if (!rows.length || !visibleSeries.length) return drawEmptyChart(canvas, height);
const sortedRows = [...rows].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
const { ctx, width } = prepareCanvas(canvas, height);
const prepared = prepareCanvas(canvas, height);
const { ctx, width } = prepared;
height = prepared.height;
const text = cssColor('--muted', '#888'), grid = cssColor('--grid', '#333');
const pad = { left: 54, right: 20, top: 20, bottom: 42 };
const allValues = [];
@@ -445,10 +496,15 @@ function renderLegend(host, series) {
function historyChartMarkup(id, title, hint, compact = false) {
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
const percent = Math.round(zoom * 100);
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom <= 1 ? 'disabled' : ''}></button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom >= MAX_CHART_ZOOM ? 'disabled' : ''}>+</button></div></div><div class="chart-wrap ${compact ? 'compact-chart' : ''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact ? 300 : 420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-title-actions"><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom <= 1 ? 'disabled' : ''}></button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom >= MAX_CHART_ZOOM ? 'disabled' : ''}>+</button></div><button type="button" class="chart-fullscreen-button" data-chart-fullscreen="${esc(id)}" title="${esc(tr('history.fullscreen'))}" aria-label="${esc(tr('history.fullscreen'))}">⛶</button></div></div><div class="chart-wrap ${compact ? 'compact-chart' : ''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact ? 300 : 420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
}
function historySeriesColor(index) {
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index * 67) % 360} 68% 55%)`);
}
document.addEventListener('keydown', event => {
if (event.key === 'Escape') closeChartPreview(document.querySelector('.history-chart-card.chart-fullscreen-fallback'));
});
+1 -1
View File
@@ -34,7 +34,7 @@ const app = {
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {},
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, flowSharedInputs: [], flowSharedInputValueCache: {}, flowSharedInputValueRequests: {}, flowSharedInputValueTimer: null,
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false,
flowDraft: null, flowSelectedNodeId: null, flowSelectedNodeIds: [], flowConnectFrom: null, flowDirty: false, flowZoom: 1,
};
try { app.savedCharts = JSON.parse(localStorage.getItem('gree_controller_saved_charts') || '[]'); } catch (_) { app.savedCharts = []; }
+7 -7
View File
@@ -51,7 +51,7 @@ function deviceCard(device, detailed = false) {
${device.supports_turbo === false ? '' : `<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}"${locked}>${esc(tr('devices.turbo'))}</button>`}
</div>
${detailed ? deviceFeaturePanel(device) : ''}
${detailed ? `<div class="card-footer">${networkInfo}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
${detailed ? `<div class="card-footer">${networkInfo}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.nameProtocol'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>` : ''}
</article>`;
}
@@ -307,7 +307,7 @@ function zoneCard(zone, detailed = true) {
<div class="sensor-detail">${esc(sensorDetails)}</div>
${compressorQueuePanel(zone)}
${manualTakeover}
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>
</article>`;
}
@@ -389,7 +389,7 @@ function groupCard(group, detailed = false) {
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house', 'heat', 'cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}"${climateControlsDisabled}>${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto', 'comfort', 'sleep', 'away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}"${climateControlsDisabled}>${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}"${climateControlsDisabled}>${esc(tr('preset.custom'))}</button></div>
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button><button type="button" class="secondary" data-action="group-custom-cancel" data-id="${esc(group.id)}">${esc(tr('actions.cancel'))}</button></div></label>${customDraftPending ? `<small class="group-custom-draft-note">${esc(tr('groups.customDraftPending'))}</small>` : ''}</div></div>
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', { count: state.zones.length }))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', { count: state.zones.length }))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>` : ''}
</article>`;
}
@@ -453,9 +453,9 @@ function renderSchedules() {
$('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => {
const zone = app.zones.find(z => z.id === item.zone_id);
const days = item.weekdays.map(day => dayNames[day - 1]).join(', ');
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
return `<article class="list-card ${item.flow_id ? 'flow-generated-card' : ''}"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('schedules.enabledState') : tr('schedules.disabledState'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : tr('schedules.crossMidnight'))}</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button>`}</div></div></article>`;
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : tr('schedules.crossMidnight'))}</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></details>`}</div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
}
@@ -467,9 +467,9 @@ function renderAutomations() {
const targetName = actionZone?.name || actionGroup?.name || actionDevice?.name || tr('common.noDevice');
const mode = item.action.mode === 'auto' && (actionGroup || actionZone) ? tr('groups.followHouse') : (item.action.mode ? modeLabel(item.action.mode) : '—');
const preset = item.action_zone_preset ? zonePresetLabel(item.action_zone_preset) : (item.action_preset ? zonePresetLabel(item.action_preset) : '—');
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
return `<article class="list-card ${item.flow_id ? 'flow-generated-card' : ''}"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(tr('automations.triggerSummary', { trigger: automationTriggerLabel(item), device: targetName }))}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}</span></div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.power'))}</small><strong>${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}</strong></div><div class="card-stat"><small>${esc(tr('common.mode'))}</small><strong>${esc(mode)}</strong></div><div class="card-stat"><small>${esc(tr('groups.profile'))}</small><strong>${esc(preset)}</strong></div><div class="card-stat"><small>${esc(tr('automations.last'))}</small><strong>${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }) : '—'}</strong></div></div>
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : (actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`))} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button>`}</div></div></article>`;
<div class="card-footer"><small>${esc(item.flow_id ? tr('flow.generatedReadOnly') : (actionGroup ? `${tr('groups.group')}: ${targetName}` : `${tr('common.device')}: ${targetName}`))} · ${esc(tr('common.cooldown'))} ${item.cooldown_seconds}s</small><div class="card-menu">${item.flow_id ? `<button data-action="edit-flow" data-id="${esc(item.flow_id)}">${esc(tr('flow.openEditor'))}</button>` : `<button data-action="edit-automation" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">•••</summary><div class="card-overflow-menu"><button class="danger" data-action="delete-automation" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></details>`}</div></div></article>`;
}).join('') : `<div class="empty"><strong>${esc(tr('automations.emptyTitle'))}</strong>${esc(tr('automations.emptyText'))}</div>`;
}
+4
View File
@@ -49,6 +49,10 @@ document.addEventListener('click', async event => {
}
return;
}
if (button.dataset.chartFullscreen) {
await toggleChartFullscreen(button.dataset.chartFullscreen);
return;
}
if (button.dataset.chartZoom) {
const id = button.dataset.chartId;
const current = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
+105 -18
View File
@@ -97,10 +97,10 @@ function flowDefaultConfig(kind) {
function renderFlows() {
const host = $('#flowList'); if (!host) return;
const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length });
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled && !flow.draft ? '' : 'is-disabled'} ${flow.draft ? 'is-draft' : ''}">
<div class="list-card-head"><div><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div>${flow.draft ? `<button type="button" class="zone-enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span></span>${esc(tr('common.disabled'))}</button>` : `<button type="button" class="zone-enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${flow.enabled ? '' : ''}</span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</button>`}</div>
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled && !flow.draft ? '' : 'is-disabled'} ${flow.draft ? 'is-draft' : ''}" data-flow-card-id="${esc(flow.id)}" tabindex="0" aria-label="${esc(`${tr('flow.openEditor')}: ${flow.name}`)}">
<div class="list-card-head"><div><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div>${flow.draft ? `<button type="button" class="zone-enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span></span>${esc(tr('flow.disabledState'))}</button>` : `<button type="button" class="zone-enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${flow.enabled ? '' : ''}</span>${esc(flow.enabled ? tr('flow.enabledState') : tr('flow.disabledState'))}</button>`}</div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></div>
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button class="card-primary-action" data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}"></summary><div class="card-overflow-menu"><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
}
@@ -150,13 +150,16 @@ function openFlowEditor(id = '', { push = true } = {}) {
const flow = id ? app.flows.find(item => item.id === id) : null;
if (id && !flow) return toast(tr('flow.notFound'), true);
app.flowDraft = flowDraftFrom(flow);
app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false;
app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; app.flowZoom = 1;
app.flowNameEditing = !flow;
renderFlowNameMode();
$('#flowEnabled').checked = app.flowDraft.enabled !== false;
$('#flowEditor').hidden = false;
document.body.classList.add('flow-editor-open');
$('#flowNaturalPreview')?.classList.remove('is-expanded');
$('#flowNaturalPreview .flow-preview-toggle')?.setAttribute('aria-expanded', 'false');
renderFlowEditor();
requestAnimationFrame(() => { if (window.matchMedia('(max-width: 760px)').matches && app.flowDraft?.nodes?.length) fitFlowToView({ maxZoom: .9 }); });
startFlowSharedInputValueRefresh();
if (push) updateBrowserUrl(`/flows/${flow?.id || 'new'}`);
}
@@ -164,6 +167,8 @@ function openFlowEditor(id = '', { push = true } = {}) {
function closeFlowEditor({ push = true, force = false } = {}) {
if (!force && app.flowDirty && !confirm(tr('confirm.discardChanges'))) return false;
stopFlowSharedInputValueRefresh();
const mobileActions = $('#flowEditorActionsDialog'); if (mobileActions?.open) mobileActions.close();
const blockDialog = $('#flowBlockDialog'); if (blockDialog?.open) blockDialog.close();
$('#flowEditor').hidden = true; document.body.classList.remove('flow-editor-open');
app.flowDraft = null; app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowConnectFrom = null; app.flowDirty = false; app.flowNameEditing = false;
if (push) updateBrowserUrl('/flows');
@@ -346,6 +351,65 @@ function flowOperatorLabel(op) { return ({ lt: '<', lte: '≤', gt: '>', gte: '
function flowDurationLabel(seconds) { const value = Number(seconds || 0); return value >= 60 && value % 60 === 0 ? `${value / 60} min` : `${value} s`; }
function flowNodeById(id) { return app.flowDraft?.nodes?.find(node => node.id === id); }
function renderFlowEnabledLabel() {
const input = $('#flowEnabled'), label = $('#flowEnabledLabel');
if (input && label) label.textContent = tr(input.checked ? 'flow.enabledState' : 'flow.disabledState');
}
function renderFlowSaveStatus() {
const status = $('#flowSaveStatus');
if (!status || !app.flowDraft) return;
const isNew = !app.flowDraft.id;
status.textContent = app.flowDirty ? tr('flow.unsavedChanges') : isNew ? tr('flow.notSavedYet') : tr('flow.savedState');
status.classList.toggle('is-dirty', app.flowDirty || isNew);
}
function setFlowZoom(value, { render = true } = {}) {
const next = clamp(Number(value) || 1, .45, 1.35);
app.flowZoom = Math.round(next * 20) / 20;
const canvas = $('#flowCanvas');
if (canvas) canvas.style.zoom = String(app.flowZoom);
const label = $('#flowZoomLabel');
if (label) label.textContent = `${Math.round(app.flowZoom * 100)}%`;
if (render) requestAnimationFrame(renderFlowEdges);
}
function fitFlowToView({ maxZoom = 1 } = {}) {
const workspace = $('#flowWorkspace');
const nodes = app.flowDraft?.nodes || [];
if (!workspace || !nodes.length) { setFlowZoom(1); if (workspace) workspace.scrollTo({ left:0, top:0, behavior:'smooth' }); return; }
const width = 170, height = 96, pad = 56;
const minX = Math.max(0, Math.min(...nodes.map(node => Number(node.x || 0))) - pad);
const minY = Math.max(0, Math.min(...nodes.map(node => Number(node.y || 0))) - pad);
const maxX = Math.max(...nodes.map(node => Number(node.x || 0) + width)) + pad;
const maxY = Math.max(...nodes.map(node => Number(node.y || 0) + height)) + pad;
const contentW = Math.max(1, maxX - minX), contentH = Math.max(1, maxY - minY);
const availableW = Math.max(220, workspace.clientWidth - 24), availableH = Math.max(180, workspace.clientHeight - 24);
const zoom = clamp(Math.min(availableW / contentW, availableH / contentH, maxZoom), .45, 1.35);
setFlowZoom(zoom);
requestAnimationFrame(() => workspace.scrollTo({ left:Math.max(0, minX * app.flowZoom - 12), top:Math.max(0, minY * app.flowZoom - 12), behavior:'smooth' }));
}
function renderFlowBlockLibrary(filter = '') {
const host = $('#flowBlockLibrary'); if (!host) return;
const query = String(filter || '').trim().toLocaleLowerCase(locale());
const groups = $$('.flow-palette .flow-palette-group');
host.innerHTML = groups.map(group => {
const title = group.querySelector(':scope > span')?.textContent?.trim() || '';
const buttons = $$('[data-flow-add]', group).filter(button => !query || `${title} ${button.textContent}`.toLocaleLowerCase(locale()).includes(query));
if (!buttons.length) return '';
const categoryClass = [...group.classList].find(name => name.startsWith('flow-palette-') && name !== 'flow-palette-group') || '';
return `<section class="flow-block-library-group ${esc(categoryClass)}"><h3>${esc(title)}</h3><div>${buttons.map(button => `<button type="button" class="secondary" data-flow-add="${esc(button.dataset.flowAdd)}">${esc(button.textContent.trim())}</button>`).join('')}</div></section>`;
}).join('') || `<div class="empty compact"><strong>${esc(tr('flow.noBlocksFound'))}</strong><span>${esc(tr('flow.noBlocksFoundHint'))}</span></div>`;
}
function openFlowBlockLibrary() {
const search = $('#flowBlockSearch'); if (search) search.value = '';
renderFlowBlockLibrary('');
$('#flowBlockDialog')?.showModal();
requestAnimationFrame(() => search?.focus());
}
function renderFlowEditor() {
const draft = app.flowDraft; if (!draft) return;
const nodesHost = $('#flowNodes');
@@ -360,7 +424,7 @@ function renderFlowEditor() {
}).join('');
$('#flowEmptyHint').hidden = draft.nodes.length > 0;
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : '';
renderFlowNameMode(); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues();
renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render:false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues();
const status = $('#flowCompileStatus');
status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
status.classList.toggle('flow-draft-badge', draft.draft === true);
@@ -368,13 +432,13 @@ function renderFlowEditor() {
function renderFlowEdges() {
const svg = $('#flowEdges'), workspace = $('#flowWorkspace'); if (!svg || !workspace || !app.flowDraft) return;
const rect = workspace.getBoundingClientRect();
svg.setAttribute('viewBox', `0 0 ${Math.max(workspace.clientWidth, workspace.scrollWidth)} ${Math.max(workspace.clientHeight, workspace.scrollHeight)}`);
const rect = workspace.getBoundingClientRect(), zoom = app.flowZoom || 1;
svg.setAttribute('viewBox', '0 0 2400 1500');
svg.innerHTML = app.flowDraft.edges.map(edge => {
const from = $(`[data-flow-node="${CSS.escape(edge.from)}"]`), to = $(`[data-flow-node="${CSS.escape(edge.to)}"]`); if (!from || !to) return '';
const a = from.getBoundingClientRect(), b = to.getBoundingClientRect();
const x1 = a.right - rect.left + workspace.scrollLeft - 2, y1 = a.top - rect.top + workspace.scrollTop + a.height / 2;
const x2 = b.left - rect.left + workspace.scrollLeft + 2, y2 = b.top - rect.top + workspace.scrollTop + b.height / 2;
const x1 = (a.right - rect.left + workspace.scrollLeft) / zoom - 2, y1 = (a.top - rect.top + workspace.scrollTop + a.height / 2) / zoom;
const x2 = (b.left - rect.left + workspace.scrollLeft) / zoom + 2, y2 = (b.top - rect.top + workspace.scrollTop + b.height / 2) / zoom;
const bend = Math.max(55, Math.abs(x2 - x1) * .45);
return `<path data-flow-edge="${esc(edge.id)}" d="M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}"/>`;
}).join('');
@@ -401,7 +465,8 @@ function sharedFlowReferenceComparisonFields(item, config) {
function renderFlowInspector() {
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
if (!node) { host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.selectBlock'))}</strong><span>${esc(tr('flow.selectBlockHint'))}</span></div>`; return; }
host.classList.toggle('has-selection', Boolean(node));
if (!node) { host.classList.remove('is-expanded'); host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.selectBlock'))}</strong><span>${esc(tr('flow.selectBlockHint'))}</span></div>`; return; }
const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind };
let fields = '';
if (node.kind === 'weekday') fields = `<div class="flow-day-grid">${[1,2,3,4,5,6,7].map(day => `<label><input type="checkbox" data-flow-config="days" value="${day}" ${(c.days || []).includes(day) ? 'checked' : ''}><span>${esc(tr(`day.${day}`))}</span></label>`).join('')}</div>`;
@@ -440,8 +505,8 @@ function renderFlowInspector() {
else if (node.kind === 'zone_thermostat') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label><div class="two"><label><span>${esc(tr('schedules.profile'))}</span><select data-flow-config="preset">${['auto','comfort','sleep','away','custom'].map(v => `<option value="${v}" ${c.preset === v ? 'selected' : ''}>${esc(zonePresetLabel(v))}</option>`).join('')}</select></label><label><span>${esc(tr('common.targetC'))}</span><input type="number" min="8" max="30" step="0.5" data-flow-config="setpoint" value="${Number(c.setpoint ?? 21)}"></label></div><div class="two"><label><span>${esc(tr('common.mode'))}</span><select data-flow-config="mode"><option value="auto" ${c.mode === 'auto' ? 'selected' : ''}>Auto</option><option value="heat" ${c.mode === 'heat' ? 'selected' : ''}>${esc(tr('mode.heat'))}</option><option value="cool" ${c.mode === 'cool' ? 'selected' : ''}>${esc(tr('mode.cool'))}</option></select></label><label><span>${esc(tr('common.power'))}</span><select data-flow-config="power"><option value="" ${c.power == null ? 'selected' : ''}>${esc(tr('actions.noChange'))}</option><option value="true" ${c.power === true ? 'selected' : ''}>${esc(tr('common.on'))}</option><option value="false" ${c.power === false ? 'selected' : ''}>${esc(tr('common.off'))}</option></select></label></div><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'device_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowActionFields(c, false)}`;
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><button type="button" class="danger" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">↕</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">×</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
}
function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq','='],['neq','≠']].map(([v,l]) => `<option value="${v}" ${v === (c.operator || 'eq') ? 'selected' : ''}>${l}</option>`).join(''); return `<div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${options}</select></label><label><span>${esc(tr('flow.value'))}</span><input data-flow-config="value" value="${esc(c.value ?? '')}"></label></div>`; }
@@ -540,8 +605,12 @@ function flowUpstreamNodes(id) {
function addFlowNode(kind) {
if (!app.flowDraft || !FLOW_NODE_META[kind]) return;
const count = app.flowDraft.nodes.length;
const node = { id: newFlowId('node'), kind, x: 80 + (count % 5) * 190, y: 70 + Math.floor(count / 5) * 130, config: flowDefaultConfig(kind) };
const workspace = $('#flowWorkspace');
const viewportX = workspace ? (workspace.scrollLeft / (app.flowZoom || 1)) + 48 : 80;
const viewportY = workspace ? (workspace.scrollTop / (app.flowZoom || 1)) + 54 : 70;
const node = { id: newFlowId('node'), kind, x: Math.max(36, viewportX + (count % 3) * 24), y: Math.max(36, viewportY + (count % 3) * 24), config: flowDefaultConfig(kind) };
app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor();
requestAnimationFrame(() => $(`[data-flow-node="${CSS.escape(node.id)}"]`)?.scrollIntoView({ block:'center', inline:'center', behavior:'smooth' }));
}
function removeFlowNode(id) {
if (!app.flowDraft) return;
@@ -1071,7 +1140,7 @@ document.addEventListener('pointerdown', event => {
});
document.addEventListener('pointermove', event => {
if (!flowDrag || !app.flowDraft) return;
let dx = event.clientX - flowDrag.x, dy = event.clientY - flowDrag.y;
let dx = (event.clientX - flowDrag.x) / (app.flowZoom || 1), dy = (event.clientY - flowDrag.y) / (app.flowZoom || 1);
const minLeft = Math.min(...flowDrag.starts.map(item => item.left));
const minTop = Math.min(...flowDrag.starts.map(item => item.top));
dx = Math.max(dx, 12 - minLeft); dy = Math.max(dy, 12 - minTop);
@@ -1080,11 +1149,13 @@ document.addEventListener('pointermove', event => {
node.x = start.left + dx; node.y = start.top + dy;
const el = $(`[data-flow-node="${CSS.escape(node.id)}"]`); if (el) { el.style.left = `${node.x}px`; el.style.top = `${node.y}px`; }
});
app.flowDirty = true; renderFlowEdges();
app.flowDirty = true; renderFlowSaveStatus(); renderFlowEdges();
});
document.addEventListener('pointerup', () => { flowDrag = null; });
document.addEventListener('keydown', event => {
const flowCard = event.target.closest?.('[data-flow-card-id]');
if (flowCard && event.target === flowCard && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); openFlowEditor(flowCard.dataset.flowCardId); return; }
if ($('#flowEditor')?.hidden || !app.flowDraft) return;
if (event.target.closest?.('input,select,textarea,[contenteditable="true"]')) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); selectAllFlowNodes(); return; }
@@ -1092,8 +1163,10 @@ document.addEventListener('keydown', event => {
});
document.addEventListener('click', event => {
const flowCard = event.target.closest?.('[data-flow-card-id]');
if (flowCard && !event.target.closest?.('button,summary,details,input,select,textarea,a,label')) { openFlowEditor(flowCard.dataset.flowCardId); return; }
const edge = event.target.closest?.('[data-flow-edge]'); if (edge) { removeFlowEdge(edge.dataset.flowEdge); return; }
const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); return; }
const add = event.target.closest?.('[data-flow-add]'); if (add) { addFlowNode(add.dataset.flowAdd); const dialog = add.closest?.('#flowBlockDialog'); if (dialog?.open) dialog.close(); return; }
const templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; }
const templateFavorite = event.target.closest?.('[data-flow-template-favorite]'); if (templateFavorite) { toggleFlowPresetFavorite(templateFavorite.dataset.flowTemplateFavorite); return; }
const templatePreview = event.target.closest?.('[data-flow-template-preview]'); if (templatePreview) { previewFlowTemplate(templatePreview.dataset.flowTemplatePreview); return; }
@@ -1103,11 +1176,24 @@ document.addEventListener('click', event => {
const input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; }
const node = event.target.closest?.('[data-flow-node]'); if (node) { if (!(app.flowSelectedNodeIds || []).includes(node.dataset.flowNode)) app.flowSelectedNodeIds = [node.dataset.flowNode]; app.flowSelectedNodeId = node.dataset.flowNode; renderFlowEditor(); return; }
const actionButton = event.target.closest?.('[data-action]'); const action = actionButton?.dataset.action;
const mobileActionsDialog = actionButton?.closest?.('#flowEditorActionsDialog');
if (mobileActionsDialog?.open && action !== 'flow-mobile-actions') mobileActionsDialog.close();
if (action === 'new-flow') openFlowEditor();
else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id);
else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id);
else if (action === 'toggle-flow-enabled') toggleFlowEnabled(actionButton.dataset.id, actionButton.dataset.value === 'true');
else if (action === 'save-flow') saveFlow();
else if (action === 'flow-mobile-actions') $('#flowEditorActionsDialog')?.showModal();
else if (action === 'flow-add-block') openFlowBlockLibrary();
else if (action === 'flow-zoom-out') setFlowZoom((app.flowZoom || 1) - .1);
else if (action === 'flow-zoom-in') setFlowZoom((app.flowZoom || 1) + .1);
else if (action === 'flow-fit') fitFlowToView();
else if (action === 'flow-preview-toggle') {
const preview = $('#flowNaturalPreview');
const expanded = preview?.classList.toggle('is-expanded') || false;
actionButton.setAttribute('aria-expanded', String(expanded));
}
else if (action === 'flow-toggle-inspector') $('#flowInspector')?.classList.toggle('is-expanded');
else if (action === 'import-flow') $('#flowImportFile')?.click();
else if (action === 'export-flow') exportFlow();
else if (action === 'flow-templates') openFlowTemplates();
@@ -1134,7 +1220,8 @@ document.addEventListener('change', event => {
});
$('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0]));
$('#flowTemplateSearch')?.addEventListener('input', event => { flowPresetSearch = event.target.value || ''; renderFlowPresetBrowser(flowPresetActiveCategory); });
$('#flowName')?.addEventListener('input', () => { if (app.flowDraft) app.flowDirty = true; });
$('#flowBlockSearch')?.addEventListener('input', event => renderFlowBlockLibrary(event.target.value));
$('#flowName')?.addEventListener('input', () => { if (app.flowDraft) { app.flowDirty = true; renderFlowSaveStatus(); } });
$('#flowName')?.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); commitFlowName(); }
else if (event.key === 'Escape') { event.preventDefault(); $('#flowName').value = app.flowDraft?.name || ''; app.flowNameEditing = false; renderFlowNameMode(); }
@@ -1143,5 +1230,5 @@ $('#flowName')?.addEventListener('blur', () => {
if (!app.flowDraft || !app.flowNameEditing) return;
if ($('#flowName').value.trim()) commitFlowName();
});
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; });
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) { app.flowDirty = true; renderFlowEnabledLabel(); renderFlowSaveStatus(); } });
window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });
+1 -1
View File
@@ -19,7 +19,7 @@ function showView(name, { push = true, scroll = true } = {}) {
if (name !== 'flows' && !$('#flowEditor')?.hidden) closeFlowEditor({ push: false, force: true });
app.currentView = name;
$$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name));
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['groups', 'flows', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
$$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['devices', 'groups', 'schedules', 'automations', 'simulation', 'night', 'homeassistant', 'settings', 'logs'].includes(name))));
if (push) updateBrowserUrl(pathForView(name));
if (scroll) window.scrollTo({ top: 0, behavior: 'smooth' });
if (name === 'dashboard') setDashboardTab(app.dashboardTab, { scroll: false });
+17 -1
View File
@@ -8,6 +8,18 @@ function renderAccessTokens() {
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('settings.noTokens'))}</strong>${esc(tr('settings.noTokensHint'))}</div>`;
}
function metricHaEntities() {
return new Set([
app.settings?.home_assistant?.outdoor_entity_id,
...app.zones.filter(zone => ['home_assistant', 'combined'].includes(zone.sensor_source)).map(zone => zone.ha_entity_id),
...app.historyData.sensors.map(row => row.entity_id),
].filter(Boolean));
}
function flowHaEntities() {
return new Set((app.flowSharedInputs || []).map(item => item?.config?.entity_id).filter(Boolean));
}
function knownHaEntities() {
return [...new Set([
...Object.keys(app.sensorAliases || {}),
@@ -22,7 +34,11 @@ function knownHaEntities() {
function renderSensorAliases() {
const host = $('#sensorAliasList'); if (!host) return;
const entities = knownHaEntities();
host.innerHTML = entities.length ? entities.map(entity => `<div class="sensor-alias-row"><span class="mono" title="${esc(entity)}">${esc(entity)}</span><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">×</button></div>`).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
const metricEntities = metricHaEntities(), flowEntities = flowHaEntities();
host.innerHTML = entities.length ? entities.map(entity => {
const badges = `${metricEntities.has(entity) ? `<span class="sensor-source-badge metrics">${esc(tr('settings.sensorMetricBadge'))}</span>` : ''}${flowEntities.has(entity) ? `<span class="sensor-source-badge flow">${esc(tr('settings.sensorFlowBadge'))}</span>` : ''}`;
return `<div class="sensor-alias-row"><div class="sensor-alias-entity"><span class="mono" title="${esc(entity)}">${esc(entity)}</span>${badges ? `<span class="sensor-source-badges">${badges}</span>` : ''}</div><input data-sensor-alias="${esc(entity)}" value="${esc(app.sensorAliases?.[entity] || '')}" placeholder="${esc(tr('settings.aliasPlaceholder'))}"><button type="button" class="sensor-alias-clear" data-clear-sensor-alias="${esc(entity)}" title="${esc(tr('actions.clear'))}">×</button></div>`;
}).join('') : `<div class="empty compact">${esc(tr('settings.noSensorAliases'))}</div>`;
}
function flowSharedInputKinds() {
+337 -2
View File
@@ -35,7 +35,7 @@
--grid: rgba(255, 255, 255, .07);
--scroll-thumb: #444444;
--scroll-thumb-hover: #575757;
--radius: 22px;
--radius: 16px;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@@ -6180,7 +6180,7 @@ body.flow-editor-open { overflow:hidden; }
.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); }
.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; }
.flow-editor-title > div { display:grid; gap:2px; min-width:0; }
.flow-name-wrap { min-width:280px; min-height:36px; display:flex; align-items:center; }
.flow-name-wrap { min-width:280px; min-height:36px; display:flex; align-items:center; gap:8px; }
.flow-name-view { display:flex; align-items:center; gap:6px; min-width:0; }
.flow-name-view[hidden] { display:none !important; }
.flow-name-text { appearance:none; border:0; background:transparent; color:var(--text); font:inherit; font-weight:750; font-size:1.12rem; line-height:1.25; padding:4px 2px; min-width:0; max-width:min(52vw,620px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:text; text-align:left; }
@@ -6426,3 +6426,338 @@ body.flow-editor-open { overflow:hidden; }
.flow-template-preview-node-trigger { border-top:3px solid var(--warning); }
.flow-template-preview-node-time, .flow-template-preview-node-timeop { border-top:3px solid var(--blue); }
.flow-template-preview-node-haaction { border-top:3px solid var(--accent); }
/* Compact Flow state control and mobile action entry point. */
.flow-enabled { white-space:nowrap; }
.flow-enabled input { width:16px; min-height:16px; accent-color:var(--accent); }
.flow-mobile-actions-button { display:none; }
.flow-inspector-head-actions { display:flex; align-items:center; gap:7px; }
.flow-inspector-close { display:none; width:34px; height:34px; padding:0; font-size:1.1rem; }
/* Clarify which HA aliases are metric sources and which are only Flow references. */
.sensor-alias-entity { min-width:0; display:flex; align-items:center; gap:7px; }
.sensor-alias-entity > .mono { min-width:0; overflow:hidden; color:var(--muted); font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
.sensor-source-badges { display:flex; flex:0 0 auto; gap:4px; }
.sensor-source-badge { display:inline-flex; align-items:center; min-height:20px; padding:2px 6px; border:1px solid var(--line); border-radius:999px; font-size:.64rem; font-weight:750; line-height:1; white-space:nowrap; }
.sensor-source-badge.metrics { border-color:var(--accent-border); background:var(--accent-soft); color:var(--accent); }
.sensor-source-badge.flow { background:var(--surface); color:var(--muted); }
/* UX refresh: clearer hierarchy, safer destructive actions and touch-first layouts. */
:root { --radius-small:10px; --radius-medium:14px; }
.device-card,.panel,.list-card { border-radius:var(--radius); }
button { border-radius:11px; }
.metric { border-radius:14px; }
.lead { max-width:780px; line-height:1.55; }
.section-heading h1 { margin-bottom:0; }
.automation-tabs {
display:flex; align-items:center; gap:5px; width:max-content; max-width:100%;
margin:-2px 0 14px; padding:5px; overflow-x:auto; border:1px solid var(--line);
border-radius:14px; background:var(--surface); scrollbar-width:none;
}
.automation-tabs::-webkit-scrollbar { display:none; }
.automation-tabs button { flex:0 0 auto; min-height:38px; padding:8px 12px; color:var(--muted); background:transparent; font-size:12px; font-weight:750; }
.automation-tabs button.active { color:var(--accent); background:var(--accent-soft); box-shadow:inset 0 0 0 1px var(--accent-border); }
.mobile-nav-only { display:block; }
.desktop-nav-only { display:none; }
.nav-section-label,.menu-section-label { color:var(--muted); font-size:9px; font-weight:800; letter-spacing:.09em; text-transform:uppercase; }
.menu-section-label { display:block; padding:14px 9px 5px; }
.more-menu-list .menu-section-label:first-child { padding-top:5px; }
.card-menu { align-items:center; }
.card-primary-action { font-weight:700; }
.card-overflow { position:relative; flex:0 0 auto; }
.card-overflow summary {
display:grid; place-items:center; width:38px; height:38px; border:1px solid var(--line);
border-radius:10px; color:var(--muted); background:var(--surface-muted); cursor:pointer;
list-style:none; font-size:16px; font-weight:800; letter-spacing:.08em;
}
.card-overflow summary::-webkit-details-marker { display:none; }
.card-overflow[open] summary { color:var(--text); border-color:var(--line-strong); background:var(--surface-2); }
.card-overflow-menu {
position:absolute; z-index:25; right:0; bottom:calc(100% + 7px); min-width:150px; padding:6px;
border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:0 14px 34px rgba(0,0,0,.28);
}
.card-overflow-menu button { width:100%; text-align:left; }
.card-overflow-menu .danger { background:var(--danger-soft); }
.flow-save-status { display:inline-flex; align-items:center; min-height:24px; color:var(--muted); font-size:.72rem; font-weight:750; white-space:nowrap; }
.flow-save-status::before { content:""; width:6px; height:6px; margin-right:6px; border-radius:50%; background:var(--accent); }
.flow-save-status.is-dirty { color:var(--warning); }
.flow-save-status.is-dirty::before { background:var(--warning); }
.flow-canvas { position:relative; width:2400px; height:1500px; transform-origin:0 0; }
.flow-canvas .flow-nodes,.flow-canvas .flow-edges { width:2400px; height:1500px; }
.flow-workspace-toolbar { justify-content:space-between; }
.flow-workspace-primary-actions,.flow-zoom-controls { display:flex; align-items:center; gap:7px; min-width:0; }
.flow-add-block-button { display:none; align-items:center; justify-content:center; gap:5px; }
.flow-zoom-controls .icon-button { width:32px; height:32px; min-height:32px; padding:0; font-size:17px; }
.flow-zoom-controls > span { min-width:44px; color:var(--muted); font-size:.72rem; font-weight:800; text-align:center; font-variant-numeric:tabular-nums; }
.flow-fit-button { min-height:32px; padding:6px 9px; font-size:.75rem; }
.flow-inspector-expand { display:none; width:34px; height:34px; padding:0; }
.flow-block-dialog { width:min(620px,calc(100% - 24px)); }
.flow-block-dialog .dialog-head { padding:16px 16px 8px; }
.flow-block-search { padding:4px 16px 10px; }
.flow-block-library { display:grid; gap:14px; padding:0 16px 18px; }
.flow-block-library-group { padding-left:10px; border-left:3px solid var(--line-strong); }
.flow-block-library-group.flow-palette-trigger { border-left-color:var(--warning); }
.flow-block-library-group.flow-palette-time { border-left-color:var(--blue); }
.flow-block-library-group.flow-palette-timeop { border-left-color:var(--info); }
.flow-block-library-group.flow-palette-sensor { border-left-color:var(--teal); }
.flow-block-library-group.flow-palette-logic { border-left-color:var(--purple); }
.flow-block-library-group.flow-palette-action { border-left-color:var(--orange); }
.flow-block-library-group.flow-palette-haaction { border-left-color:var(--accent); }
.flow-block-library-group h3 { margin:0 0 7px; color:var(--muted); font-size:.72rem; letter-spacing:.07em; text-transform:uppercase; }
.flow-block-library-group > div { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.flow-block-library-group button { min-height:42px; text-align:left; }
/* Port stays visually small but gets a comfortable touch target. */
.flow-editor .flow-port { width:16px; height:16px; margin-top:-8px; }
.flow-editor .flow-port-in { left:-9px; }
.flow-editor .flow-port-out { right:-9px; }
.flow-editor .flow-port::after { content:""; position:absolute; inset:-11px; border-radius:50%; }
.flow-preview-toggle { display:none; }
.flow-preview-details { display:flex; justify-content:space-between; gap:24px; align-items:center; width:100%; min-width:0; }
@media (min-width:900px) {
.mobile-nav-only { display:none !important; }
.desktop-nav-only { display:flex; }
.bottom-nav {
top:calc(var(--topbar-height,72px) + 14px); bottom:14px; left:max(14px,calc((100vw - 1440px)/2));
display:flex; flex-direction:column; gap:2px; width:190px; max-height:calc(100dvh - var(--topbar-height,72px) - 28px);
padding:10px; overflow-y:auto; border-radius:16px; scrollbar-width:thin;
}
.bottom-nav .nav-section-label { padding:10px 9px 4px; }
.bottom-nav .nav-section-label:first-child { padding-top:3px; }
.bottom-nav button { display:flex; align-items:center; justify-content:flex-start; gap:10px; min-height:39px; padding:8px 10px; border-radius:10px; text-align:left; }
.bottom-nav button span { display:grid; place-items:center; flex:0 0 24px; width:24px; font-size:17px; }
.bottom-nav button b { min-width:0; overflow:hidden; font-size:11px; text-overflow:ellipsis; white-space:nowrap; }
.bottom-nav button.active { background:var(--accent-soft); }
main { width:min(1440px,100%); padding-left:222px; padding-right:24px; }
}
@media (max-width:760px) {
main { padding:18px 12px calc(94px + env(safe-area-inset-bottom)); }
.desktop-nav-only { display:none !important; }
.mobile-nav-only { display:grid; }
.bottom-nav { grid-template-columns:repeat(5,minmax(0,1fr)); }
.bottom-nav button { min-height:48px; }
.bottom-nav button span { font-size:19px; }
.section-heading { gap:12px; margin:4px 0 14px; }
.section-heading h1 { font-size:26px; }
.section-heading > button,.section-heading > .section-actions,.section-heading > .section-heading-actions { width:100%; }
.section-heading > button { min-height:44px; }
.flow-page-actions,.section-heading-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.flow-page-actions button,.section-heading-actions button { width:100%; min-height:44px; }
.automation-tabs { width:100%; margin-bottom:12px; scroll-snap-type:x proximity; }
.automation-tabs button { flex:1 0 auto; scroll-snap-align:start; }
.device-card,.panel,.list-card { border-radius:14px; }
.device-card { padding:15px; }
.card-footer { gap:9px; }
.card-menu { width:100%; justify-content:flex-end; }
.card-menu > button { min-height:40px; }
.card-primary-action { flex:1 1 auto; }
.card-overflow summary { width:40px; height:40px; }
dialog:not(.auth-dialog) {
inset:auto 0 0; width:100%; max-width:none; max-height:88dvh; margin:0;
border-right:0; border-bottom:0; border-left:0; border-radius:18px 18px 0 0;
overscroll-behavior:contain;
}
dialog:not(.auth-dialog) .dialog-head { position:sticky; top:0; z-index:5; padding-top:14px; background:var(--dialog-bg); }
dialog:not(.auth-dialog) .dialog-head::before { content:""; position:absolute; top:6px; left:50%; width:38px; height:4px; border-radius:999px; background:var(--line-strong); transform:translateX(-50%); }
.dialog-form { padding:14px 14px calc(14px + env(safe-area-inset-bottom)); }
.dialog-form .form-actions { position:sticky; bottom:0; z-index:4; margin:6px -14px -14px; padding:10px 14px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:color-mix(in srgb,var(--dialog-bg) 96%,transparent); backdrop-filter:blur(12px); }
.dialog-form .form-actions > button { flex:1 1 0; min-height:44px; }
.history-tabs { margin-inline:-12px; padding-inline:12px; scroll-padding-inline:12px; scroll-snap-type:x proximity; }
.history-tabs button { min-height:40px; scroll-snap-align:start; }
.history-toolbar-panel { padding:12px; }
.settings-mode-tabs { position:sticky; top:calc(var(--topbar-height,64px) + 6px); z-index:8; margin-inline:-4px; background:color-mix(in srgb,var(--bg) 94%,transparent); backdrop-filter:blur(12px); }
.settings-save-bar { bottom:calc(74px + env(safe-area-inset-bottom)); margin-inline:-4px; padding:10px; }
.settings-save-bar span { display:none; }
.settings-save-bar button { width:100%; min-height:44px; }
/* Mobile Flow: compact header, canvas-first editor and bottom-sheet inspector. */
.flow-editor-bar {
display:grid; grid-template-columns:minmax(0,1fr) auto; grid-template-rows:auto auto;
align-items:center; width:100%; gap:6px 8px; padding:7px 8px; overflow:hidden;
}
.flow-editor-title { grid-column:1; grid-row:1; width:100%; min-width:0; gap:8px; }
.flow-editor-title > div { width:100%; min-width:0; }
.flow-editor-title .eyebrow { display:none; }
.flow-name-wrap { display:flex; width:100%; min-width:0; gap:6px; }
.flow-name-view { min-width:0; flex:1 1 auto; }
.flow-name-text { min-width:0; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.flow-editor-title input { width:100%; min-width:0; flex:1 1 auto; font-size:1rem; }
.flow-editor-bar > .flow-save-button {
grid-column:2; grid-row:1; align-self:stretch; min-width:0; min-height:38px;
padding-inline:11px; white-space:nowrap;
}
.flow-editor-actions {
grid-column:1 / -1; grid-row:2; display:flex; align-items:center; justify-content:space-between;
width:100%; min-width:0; max-width:none; gap:8px; overflow:hidden;
}
.flow-editor-actions .flow-desktop-action,.flow-save-status,.flow-compile-status { display:none; }
.flow-editor-actions .flow-enabled {
display:flex; flex:0 1 auto; width:auto; min-width:0; min-height:34px;
justify-content:flex-start; gap:7px; padding:0 4px; font-size:.78rem;
}
.flow-editor-actions .flow-enabled span { display:inline; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.flow-editor-actions .flow-mobile-actions-button {
display:inline-flex; flex:0 0 auto; width:auto; min-width:96px; min-height:34px;
margin-left:auto; padding:6px 12px; justify-content:center;
}
.flow-editor-body { position:relative; display:flex; flex:1 1 auto; flex-direction:column; height:0; min-width:0; min-height:0; overflow:hidden; }
.flow-workspace-wrap {
order:1; display:grid; grid-template-rows:auto minmax(0,1fr) auto; flex:1 1 auto;
width:100%; height:100%; min-width:0; min-height:0; overflow:hidden;
}
.flow-workspace-toolbar { display:flex; flex:0 0 auto; width:100%; min-width:0; min-height:48px; padding:6px 8px; gap:7px; justify-content:space-between; overflow:hidden; }
.flow-workspace-primary-actions { display:flex; flex:1 1 auto; min-width:0; overflow:hidden; }
.flow-add-block-button { display:inline-flex; min-width:0; max-width:128px; min-height:36px; padding:7px 10px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.flow-selection-action,#flowSelectionCount { display:none; }
.flow-zoom-controls { display:flex; flex:0 0 auto; margin-left:auto; gap:4px; }
.flow-zoom-controls .icon-button { width:34px; height:34px; min-height:34px; }
.flow-zoom-controls > span { display:none; }
.flow-fit-button { width:34px; min-width:34px; min-height:34px; padding:0; font-size:0; }
.flow-fit-button::before { content:"⊡"; font-size:17px; line-height:1; }
.flow-palette { display:none !important; }
.flow-workspace { height:100%; min-width:0; min-height:0 !important; max-height:none; overflow:auto; overscroll-behavior:contain; touch-action:pan-x pan-y; }
.flow-natural-preview { display:block; flex:0 0 auto; min-height:0; max-height:none; padding:0; overflow:hidden; }
.flow-preview-toggle {
display:flex; align-items:center; justify-content:space-between; width:100%; min-height:38px;
padding:7px 10px; border:0; border-radius:0; background:var(--surface); color:var(--text); text-align:left;
}
.flow-preview-toggle strong { font-size:.78rem; }
.flow-preview-toggle-icon { color:var(--muted); font-size:1rem; transition:transform .15s ease; }
.flow-preview-details { display:none; flex-direction:column; align-items:stretch; gap:7px; max-height:32dvh; padding:8px 10px 10px; overflow:auto; }
.flow-natural-preview.is-expanded .flow-preview-details { display:flex; }
.flow-natural-preview.is-expanded .flow-preview-toggle { border-bottom:1px solid var(--line); }
.flow-natural-preview.is-expanded .flow-preview-toggle-icon { transform:rotate(180deg); }
.flow-preview-item { gap:1px; }
.flow-natural-preview strong { font-size:.78rem; }
.flow-natural-preview span { font-size:.76rem; }
.flow-inspector {
display:none; position:fixed; inset:auto 0 0; width:100%; max-height:56dvh;
padding:22px 14px calc(14px + env(safe-area-inset-bottom)); overflow:auto;
border:0; border-top:1px solid var(--line-strong); border-radius:18px 18px 0 0;
z-index:95; box-shadow:0 -14px 34px rgba(0,0,0,.3); transition:max-height .18s ease;
}
.flow-inspector.has-selection { display:block; }
.flow-inspector::before { content:""; position:absolute; top:7px; left:50%; width:38px; height:4px; border-radius:999px; background:var(--line-strong); transform:translateX(-50%); }
.flow-inspector.is-expanded { max-height:90dvh; }
.flow-inspector-close,.flow-inspector-expand { display:inline-grid; place-items:center; }
.flow-inspector-head { position:sticky; top:-22px; z-index:4; margin:-8px -2px 14px; padding:10px 2px 8px; background:var(--surface); }
.flow-inspector-delete { padding-inline:10px; }
.flow-inspector .two { grid-template-columns:1fr; }
.flow-inspector label { font-size:.78rem; }
.flow-inspector input,.flow-inspector select,.flow-inspector textarea { min-height:44px; }
.flow-block-dialog { max-height:82dvh !important; }
.flow-block-library-group > div { grid-template-columns:1fr; }
.flow-block-library-group button { min-height:46px; }
.sensor-alias-row { grid-template-columns:minmax(0,1fr) 34px; }
.sensor-alias-entity { grid-column:1 / -1; }
.sensor-alias-row input { grid-column:1; }
.sensor-alias-clear { grid-column:2; }
/* Do not let long settings/help copy dominate small screens. */
.settings-block { gap:14px; }
.settings-block-head h3 { font-size:16px; }
.settings-block-head p,.field-note { line-height:1.45; }
}
@media (max-width:420px) {
.section-heading h1 { font-size:24px; }
.flow-editor-actions .flow-mobile-actions-button { min-width:88px; padding-inline:10px; }
}
/* Readability and form consistency fixes found during the wider UX pass. */
textarea { font:inherit; }
textarea {
width:100%; min-height:96px; resize:vertical; border:1px solid var(--line); border-radius:12px;
outline:0; padding:10px 12px; color:var(--text); background:var(--input-bg); line-height:1.45;
}
textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb,var(--accent) 18%,transparent); outline-offset:1px; }
.flow-empty-hint button { pointer-events:auto; margin-top:7px; justify-self:center; }
#controlPlan .plan-card>p { font-size:11.5px; line-height:1.45; }
#controlPlan .plan-events li { font-size:10.5px; line-height:1.4; }
#controlPlan .plan-rule-summary strong { font-size:11px; }
#controlPlan .plan-rule-summary small { font-size:10px; }
#controlPlan .plan-card-head .eyebrow { font-size:9px; }
@media (prefers-reduced-motion:reduce) {
*,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; }
}
.flow-card[data-flow-card-id] { cursor:pointer; }
.flow-card[data-flow-card-id]:focus-visible { outline:2px solid color-mix(in srgb,var(--accent) 55%,transparent); outline-offset:2px; }
.flow-card[data-flow-card-id] button,.flow-card[data-flow-card-id] details { cursor:pointer; }
.flow-generated-card { border-left:3px solid var(--accent-border); }
.flow-generated-card .card-footer > small { color:var(--accent); }
/* 0.11.4 production layout guards and in-app chart preview. */
html,body { max-width:100%; overflow-x:clip; }
main,.view { min-width:0; max-width:100%; }
.flow-info-panel,.flow-info-panel > div { min-width:0; }
.flow-info-panel > .badge { flex:0 1 auto; max-width:100%; white-space:normal; overflow-wrap:anywhere; text-align:center; }
body.flow-editor-open { width:100%; height:100vh; height:100dvh; overflow:hidden; overscroll-behavior:none; }
.flow-editor { width:100vw; width:100dvw; height:100vh; height:100dvh; max-width:100%; overflow:hidden; }
.flow-editor-bar,.flow-editor-title,.flow-editor-actions,.flow-editor-body,.flow-workspace-wrap,.flow-workspace-toolbar { min-width:0; max-width:100%; }
.chart-title-row > div:first-child { min-width:0; }
.chart-title-actions { display:flex; align-items:center; gap:7px; flex:0 0 auto; }
.chart-fullscreen-button { min-width:34px; height:32px; padding:0 9px; border:1px solid var(--line); background:var(--surface-muted); color:var(--text-soft); font-size:16px; line-height:1; }
.chart-fullscreen-button:hover { background:var(--surface-2); }
body.chart-fullscreen-open { overflow:hidden; }
body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-index:139; background:rgba(0,0,0,.68); backdrop-filter:blur(3px); }
.history-chart-card.chart-fullscreen-fallback {
position:fixed; left:50%; top:50%; z-index:140;
width:min(1200px,calc(100vw - 28px)); height:min(820px,calc(100dvh - 28px)); max-width:none;
margin:0; padding:14px; transform:translate(-50%,-50%);
display:grid; grid-template-rows:auto minmax(0,1fr) auto; gap:8px; overflow:hidden;
border:1px solid var(--line-strong); border-radius:16px; background:var(--surface);
box-shadow:0 28px 90px rgba(0,0,0,.58);
}
.history-chart-card.chart-fullscreen-fallback .chart-title-row { margin:0; }
.history-chart-card.chart-fullscreen-fallback .chart-title-row p,
.history-chart-card.chart-fullscreen-fallback .chart-hover-hint { display:none; }
.history-chart-card.chart-fullscreen-fallback .chart-wrap { height:100%; min-height:0; overflow:auto; border:1px solid var(--line); border-radius:10px; background:var(--surface-muted); }
.history-chart-card.chart-fullscreen-fallback .legend { max-height:88px; margin:0; overflow:auto; }
@media (max-width:760px) {
body:not(.flow-editor-open) .bottom-nav { position:fixed; right:0; bottom:0; left:0; z-index:60; max-width:100vw; overflow-x:hidden; }
.flow-info-panel { flex-direction:column; align-items:flex-start; gap:12px; padding:16px; overflow:hidden; }
.flow-info-panel > div { width:100%; }
.flow-info-panel > .badge { align-self:flex-start; text-align:left; }
.flow-shared-settings-link { width:100%; max-width:100%; white-space:normal; text-align:left; }
.automation-tabs { width:100%; min-width:0; }
.chart-title-row { gap:8px; }
.chart-title-actions { width:100%; justify-content:flex-end; }
.chart-fullscreen-button { min-height:36px; height:36px; }
.history-chart-card.chart-fullscreen-fallback { width:calc(100vw - 12px); height:calc(100dvh - 18px); padding:9px; border-radius:13px; }
.history-chart-card.chart-fullscreen-fallback .chart-title-row { flex-direction:row; align-items:center; gap:8px; }
.history-chart-card.chart-fullscreen-fallback .chart-title-row > div:first-child { flex:1 1 auto; min-width:0; }
.history-chart-card.chart-fullscreen-fallback .chart-title-row h3 { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.history-chart-card.chart-fullscreen-fallback .chart-title-actions { width:auto; flex:0 0 auto; }
.history-chart-card.chart-fullscreen-fallback .legend { max-height:70px; }
}
@media (max-width:560px) {
.chart-title-actions { gap:5px; }
.chart-zoom-controls button { height:36px; }
}