This commit is contained in:
Mateusz Gruszczyński
2026-09-02 09:02:56 +02:00
parent 9c6e51fc35
commit 43441d2fb7
16 changed files with 555 additions and 60 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.9.9"
version = "0.9.11"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.9.9"
version = "0.9.11"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+15 -15
View File
@@ -1,12 +1,12 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
b0a17a6d6445239d1e307f5e685a6b87dbf7e700a6a362f9a57e713de204a802 ./Cargo.lock
3ec8130c8c6266652fae17f214c2e57c8faf161709c2cba4d9c7da95db281031 ./Cargo.toml
71d5043c5c5892bcf5ad33dd0ae73600bc0ff9bbc10d93e963c90c173963c39e ./Cargo.lock
c446b9af75b3d07cdf2c42b7f1286f503b225d5dc2ed3592cce4df74de31db21 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
081853b6c3874cda25374f86ba30f1a3796aeefc498bfb486f752f2741e59fc0 ./README.md
21b3a4808f6d47b3cb283be03faf8ec1327f2a757b247277c0299468c8a0d74c ./README.md
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
6275b530ace5c60b43036665feb5e5fd29f5bda784ec48f56d3f9969caeb70d1 ./docs/API.md
e0642446a28e7241d5e418896850b0ecd61d272c2df74fd0bf3cd3d9addde17a ./docs/FLOW.md
05eafc8541a867aba3121bd0df1ba475305005841c8a2437a667484a232e80e4 ./docs/API.md
5322117989c854763d87a4ea6ec5e957863d370ca4ebdc332e2c8c11f23338d1 ./docs/FLOW.md
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.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
2302987becea57c561706fdd3f4571db4632225e11e8281b146a1fd59a3478a7 ./home-assistant/custom_components/gree_controller/manifest.json
b4a1da0fc4912485c419f53b07dc651910a5cffb23f19b9a3290be7ae1946478 ./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,8 +26,8 @@ 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
c9f0244b7b908d57fa4b9cf32fc1a999527fcfbb469e302fd62b75743f887753 ./lang/en.json
b7bcd0bfc806c4bf488ca9771635066ccea8503c9a41ab00e97fe07ee6aaf4e9 ./lang/pl.json
608a846712e3bccfade1b799843096ea9dfcbe6628804ac1a23e9ccc2cfec794 ./lang/en.json
fece587ab929200753697419de702f3abf4661a419acfac9c89d6143b761c7fd ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -80,7 +80,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
8ccca8ed49576cf0b8e3453436ac3ff9c2a222cd41b27db98ceb62bd5f87aeef ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
51b26ee9a02278f086f69d2ca5874feabcc90ebeb32149d1fa4b9f4fd55d91c2 ./src/api.rs
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
5643425214a0ad228d6229398b715d9a6823c8c5823cdb4fbf73912d9b1eb74b ./src/api/automations.rs
@@ -91,7 +91,7 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
88e04a59bfedd5e6c54e6a2938b1031964be3a1505db2ca67d6290aa042cbe3b ./src/api/integrations.rs
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
@@ -171,7 +171,7 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
142c46fa8e1b9b726c2c05f50b0b0d19ab5667683f63d8d50fde922771dce6af ./web/index.html
460c3f8599feaf87deb854766b1d815069934132140fd3c48b5c81fe62957415 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
@@ -179,16 +179,16 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.
d34953aec21ade81ca9770c9c99873b0b2a4862a1bf671a6e13b507a5dd5b7ed ./web/js/dashboard.js
2887856e35e086bb575e9fc0d8b0ed1160b6912297a2957535d5416bda68b4b7 ./web/js/entities.js
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
b7d4d428f0f40ce84d358fa90086c3161d9cac604281211ea46a11f6406fbbf6 ./web/js/flows.js
7373214514dc553c6c793afc88aa62f4c21b02c32e002a81f27d7aeabca82fdf ./web/js/flows.js
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
7f23507b2f1f632d69b466688f68f3f0bf661dc6856a1ed14a462e063114a980 ./web/js/navigation.js
c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
566702c0dafd304581c0438597578bccce1cf21c428171a36415728eb4867d04 ./web/js/settings-ui.js
d299216bb707f494be77e0d46f87b203cf1a2c020e0036af86d8f5818ba03c88 ./web/js/settings.js
3d44b5457200a71f66fbf7bde89c7b9adf416ca185ad0e3c61f2c4e551bceb1e ./web/js/settings-ui.js
a06d718217dc6ffb6d4fb5b002fedac02ba7eea3a5b00066c2241df7c1957e51 ./web/js/settings.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
a1590b58b6a0e3fc632489de55a41261787f1340f7d9bf778a44d6f8ab1a19d6 ./web/styles.css
d6a7ebd22557a1dc8c74b14562a3f541c77bc0afcae88508a8e667c4ddbb775e ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+4 -4
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.9.9**
**Current release: 0.9.11**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
@@ -207,7 +207,7 @@ Schedules select profiles or a custom target for selected ISO weekdays (`1=Monda
**Flow** is the authoring layer for combined schedules and automations. The full-screen editor stores a block graph as the source of truth and compiles it automatically: a pure weekday + time-range + thermostat path becomes a native schedule, while richer logic becomes a generated automation. Generated schedule/automation rows carry their Flow provenance and are read-only in the legacy editors.
The reusable Flow preset library is stored as standalone JSON files in `presets/`. Each file contains its name, description and complete Flow graph, while the UI loads the embedded preset catalog dynamically instead of hard-coding graphs in JavaScript.
The reusable Flow preset library is stored as standalone JSON files in `presets/`. Each file contains its name, description and complete Flow graph, while the UI loads the embedded preset catalog dynamically instead of hard-coding graphs in JavaScript. The library has category tabs, search, local favorites/recent presets, a graph preview and requirement checks before applying a preset.
Available blocks include weekday/time/date ranges, the application Night mode, outdoor/device/zone temperature, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability checks, a constant diagnostic source, AND/OR/NOT, thermostat actions, direct GREE actions and group actions. The GREE action uses the existing `DeviceCommand` fields (power, mode, target, fan, swing, quiet, turbo, light, air, xfan, health and sleep). Multiple branches can feed an action; the compiled runtime preserves the graph logic.
@@ -424,6 +424,6 @@ Flow reuses the existing thermostat, group and device-control domains instead of
The editor supports optimistic revisions, so saving an older copy from another browser/tab returns HTTP 409 instead of overwriting a newer graph. Flow compilation/replacement is serialized with configuration, automation, schedule and thermostat-cycle locks, and generated schedules/automations plus the Flow source are replaced in one SQLite transaction. Runtime condition evaluation happens before the automation execution lock; after acquiring it the rule is reloaded and stale snapshots are discarded. Same-cycle actions claim their target devices deterministically, and Flow/device thermostat writes take the schedule -> thermostat-cycle -> zone -> device path. Home Assistant read failures fail closed instead of making NOT/neq logic accidentally true.
Diagnostics include a non-mutating dry-run endpoint/UI with a selectable simulation time, optional per-block sensor/state overrides, a per-node condition trace and an ownership/block reason. Flow-scoped execution and dry-run events can be viewed from the editor.
Diagnostics include a non-mutating dry-run endpoint/UI with a selectable simulation time, optional per-block sensor/state overrides, a per-node condition trace and an ownership/block reason. Shared Home Assistant Flow inputs can also be tested directly in their editor against the current HA entity state. Flow-scoped execution and dry-run events can be viewed from the editor.
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. The editor includes reusable templates for workday comfort, weather-aware control, combined AND/OR demand, device-health gating, night group control, a Home Assistant window guard, presence comfort/eco, frost protection, a nested OR+NOT guard and Night-mode-driven quiet control.
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. Shared inputs show which Flows reference them and link directly to those editors. The preset library includes 37 categorized scenarios for comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
+29 -4
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.9.9**.
HTTP and WebSocket API for GREE Controller **0.9.11**.
[← Main documentation](../README.md)
@@ -179,6 +179,7 @@ Common statuses:
| GET | `/api/debug` | Read debug overlay settings. |
| PUT | `/api/debug` | Update debug overlay settings. |
| POST | `/api/integrations/home-assistant/test` | Test HA temperature read. |
| POST | `/api/integrations/home-assistant/entity` | Read raw HA entity state/attributes for shared Flow input diagnostics. |
| POST | `/api/integrations/notifications/test` | Send a test notification. |
### Access tokens and restricted Home Assistant API
@@ -212,7 +213,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.9.9",
"version": "0.9.11",
"uptime_seconds": 1234,
"control_ready": true,
"time": "2026-08-30T06:54:00Z"
@@ -236,7 +237,7 @@ Returns the initial Web UI snapshot:
"settings": {},
"outdoor_temperature": null,
"system": {
"version": "0.9.9",
"version": "0.9.11",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1201,6 +1202,30 @@ The Web UI can display **All**, **Requests** or **GREE** subsets.
}
```
### `POST /api/integrations/home-assistant/entity`
Reads the current raw Home Assistant entity document used by the shared-Flow-input diagnostics UI.
```json
{
"entity_id": "climate.gas_boiler"
}
```
Response includes the raw state, availability, attributes and timestamps:
```json
{
"ok": true,
"entity_id": "climate.gas_boiler",
"state": "heat",
"available": true,
"attributes": { "hvac_action": "heating" },
"last_changed": "2026-09-02T06:30:00Z",
"last_updated": "2026-09-02T06:30:05Z"
}
```
### `POST /api/integrations/notifications/test`
Accepts a `NotificationSettings` object. Blank secret/webhook fields reuse saved secrets for the test.
@@ -1512,4 +1537,4 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/
Existing Flow updates require `expected_revision`. A mismatched revision returns HTTP 409 to prevent stale editor tabs from overwriting newer graphs. Source Flow plus generated outputs are replaced atomically in one database transaction while configuration/automation/schedule/thermostat-cycle operations are serialized.
Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic. The template UI adds search, favorites/recent history, a graph preview and runtime requirement checks; shared inputs expose usage links and HA-backed inputs can be tested against live entity state.
+3 -1
View File
@@ -63,7 +63,9 @@ Actions include thermostat zone, GREE device and climate group. Direct GREE acti
## Built-in presets
The editor ships 37 editable presets grouped into seven categories: Comfort, Energy & cost, Protection & safety, Night, Reliability, Home Assistant / heat sources and Advanced. The library includes workday/weekend comfort, weather-aware demand, morning boost, presence and energy-price eco modes, peak-power limiting, open-window/humidity/overheat/frost guards, night profiles, sensor/device resilience, dual heating/cooling thresholds, multi-room group control, nested AND/OR/NOT Home Assistant scenarios, and gas-boiler/external-heat-source coordination including off, reduced-target, boost and fallback heating patterns.
The editor ships 37 editable presets grouped into seven categories: Comfort, Energy & cost, Protection & safety, Night, Reliability, Home Assistant / heat sources and Advanced. The library UI also provides search, Favorites, Recent, a mini graph preview and requirement checks before applying a preset. The library includes workday/weekend comfort, weather-aware demand, morning boost, presence and energy-price eco modes, peak-power limiting, open-window/humidity/overheat/frost guards, night profiles, sensor/device resilience, dual heating/cooling thresholds, multi-room group control, nested AND/OR/NOT Home Assistant scenarios, and gas-boiler/external-heat-source coordination including off, reduced-target, boost and fallback heating patterns.
Shared Flow inputs show the Flows that reference them and link directly to those editors. Home Assistant-backed shared inputs can be tested live in the input modal, including the current raw value/attribute and the resulting TRUE/FALSE condition.
Presets are data files, not JavaScript definitions. Every preset lives in `presets/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js/flows.js`.
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.9.9",
"version": "0.9.11",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
+80 -6
View File
@@ -927,8 +927,6 @@
"settings.haConnectionHint": "Server address and credentials used by all Home Assistant features.",
"settings.haThermostatSources": "Thermostat temperature sources",
"settings.haThermostatSourcesHint": "These settings belong to thermostat and temperature-source logic, not reusable Flow inputs.",
"flow.listEyebrow": "Your automations",
"flow.listTitle": "Flow list",
"flow.listCount": "{count} Flows",
"flow.quickToggleHint": "Enable or disable the Flow without opening the editor",
"flow.quickEnabled": "Flow enabled",
@@ -965,8 +963,84 @@
"flow.templateCategory.advanced.hint": "Nested AND/OR/NOT logic, multiple rooms and Home Assistant states.",
"flow.templateCategory.home_assistant": "Home Assistant / heat sources",
"flow.templateCategory.home_assistant.hint": "Coordinate GREE with a gas boiler, external heat source, heating demand and temperatures read from Home Assistant.",
"flow.templatesLoadFailed": "Unable to load preset library."
},
"flow.sharedInputsSettingsLink": "Shared Flow inputs in Home Assistant →",
"flow.templateCategories": "Preset categories"
"flow.templatesLoadFailed": "Unable to load preset library.",
"flow.sharedInputsSettingsLink": "Shared Flow inputs in Home Assistant →",
"flow.templateCategories": "Preset categories",
"flow.sharedInputTest": "Test HA input",
"flow.sharedInputTesting": "Testing…",
"flow.sharedInputTestHint": "The test uses the saved Home Assistant connection and does not save this input.",
"flow.sharedInputTestEntityRequired": "Enter entity_id before testing.",
"flow.sharedInputTestCurrent": "Current value",
"flow.sharedInputTestExpected": "Condition",
"flow.sharedInputTestMatched": "TRUE — condition matched",
"flow.sharedInputTestNotMatched": "FALSE — condition did not match",
"flow.sharedInputTestAvailable": "Entity available",
"flow.sharedInputTestUnavailable": "Entity unavailable",
"flow.sharedInputUsedBy": "Used in {count} Flows",
"flow.sharedInputUnused": "Not used in any Flow",
"flow.openReferencedFlow": "Open Flow “{name}”",
"flow.templateSearch": "Search presets…",
"flow.templateSearchLabel": "Search presets",
"flow.templateFavorites": "Favorites",
"flow.templateRecent": "Recent",
"flow.templateNoResults": "No presets match the search.",
"flow.templateNoFavorites": "You have no favorite presets yet.",
"flow.templateNoRecent": "No preset has been used yet.",
"flow.templateFavoriteAdd": "Add to favorites",
"flow.templateFavoriteRemove": "Remove from favorites",
"flow.templatePreview": "Preset preview",
"flow.templatePreviewHint": "Review requirements and the block layout before replacing the current Flow.",
"flow.templateUse": "Use preset",
"flow.templateRequirements": "Requirements",
"flow.templateRequiresHa": "Home Assistant",
"flow.templateRequiresZone": "{count} zone(s)",
"flow.templateRequiresDevice": "{count} device(s)",
"flow.templateRequiresGroup": "{count} group(s)",
"flow.templateRequiresShared": "{count} shared input(s)",
"flow.templateRequiresEntity": "HA entities configured in the preset",
"flow.templateRequirementsReady": "Requirements available",
"flow.templateRequirementsMissing": "Missing: {items}",
"flow.templateNodesCount": "{count} blocks",
"flow.templateEdgesCount": "{count} connections",
"flow.templateSelectPreview": "Select a preset to preview it.",
"flow.templateCategory.favorites": "Favorites",
"flow.templateCategory.recent": "Recent",
"flow.enable": "Enable Flow",
"flow.disable": "Disable Flow",
"flow.logLevelInfo": "Info",
"flow.logLevelWarn": "Warning",
"flow.logLevelError": "Error",
"flow.logKindCreated": "Flow created",
"flow.logKindUpdated": "Flow updated",
"flow.logKindDeleted": "Flow deleted",
"flow.logKindImported": "Flow imported",
"flow.logKindDryRun": "Flow test",
"flow.logKindConditionError": "Condition error",
"flow.logKindActionSuppressed": "Action suppressed",
"flow.logKindActionFired": "Action executed",
"flow.logKindActionError": "Action error",
"flow.logKindBlockedZone": "Blocked by zone",
"flow.logKindBlockedManual": "Blocked by manual control",
"flow.logKindBlockedThermostat": "Blocked by thermostat",
"flow.logKindBlockedTemporary": "Blocked by temporary thermostat",
"flow.logKindBlockedThermostatOwner": "Device owned by thermostat",
"flow.logKindBlockedOwnership": "Control ownership changed",
"flow.logKindConflict": "Automation conflict",
"flow.logMessageCreated": "Flow “{name}” was created.",
"flow.logMessageUpdated": "Flow “{name}” was updated.",
"flow.logMessageDeleted": "Flow “{name}” was deleted.",
"flow.logMessageImported": "Flow “{name}” was imported.",
"flow.logMessageDryRun": "Flow “{name}” was tested without changing device state.",
"flow.logMessageConditionError": "One of the Flow conditions could not be evaluated correctly.",
"flow.logMessageActionSuppressed": "The Flow action was suppressed by the current control owner.",
"flow.logMessageActionFired": "The Flow action was executed.",
"flow.logMessageActionError": "The Flow action failed to execute.",
"flow.logMessageBlockedZone": "The Flow action was blocked because the related zone is disabled.",
"flow.logMessageBlockedManual": "The Flow action was blocked by manual device control.",
"flow.logMessageBlockedThermostat": "The Flow action was blocked by the local thermostat.",
"flow.logMessageBlockedTemporary": "The Flow action was blocked by the temporary thermostat.",
"flow.logMessageBlockedThermostatOwner": "The Flow action was suppressed because the device is controlled by an enabled thermostat zone.",
"flow.logMessageBlockedOwnership": "The Flow action was suppressed because control ownership changed during execution.",
"flow.logMessageConflict": "The Flow action was skipped because another automation already claimed the same target."
}
}
+80 -6
View File
@@ -927,8 +927,6 @@
"settings.haConnectionHint": "Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.",
"settings.haThermostatSources": "Źródła temperatury sterowania",
"settings.haThermostatSourcesHint": "Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.",
"flow.listEyebrow": "Twoje automatyzacje",
"flow.listTitle": "Lista Flow",
"flow.listCount": "{count} Flow",
"flow.quickToggleHint": "Szybko włącz lub wyłącz Flow bez otwierania edytora",
"flow.quickEnabled": "Flow włączony",
@@ -965,8 +963,84 @@
"flow.templateCategory.advanced.hint": "Wielopoziomowe AND/OR/NOT, wiele pomieszczeń i stany Home Assistant.",
"flow.templateCategory.home_assistant": "Home Assistant / źródła ciepła",
"flow.templateCategory.home_assistant.hint": "Koordynacja GREE z kotłem gazowym, zewnętrznym ogrzewaniem, zapotrzebowaniem i temperaturami pobieranymi z Home Assistant.",
"flow.templatesLoadFailed": "Nie udało się wczytać biblioteki presetów."
},
"flow.sharedInputsSettingsLink": "Wspólne wejścia Flow w Home Assistant →",
"flow.templateCategories": "Kategorie presetów"
"flow.templatesLoadFailed": "Nie udało się wczytać biblioteki presetów.",
"flow.sharedInputsSettingsLink": "Wspólne wejścia Flow w Home Assistant →",
"flow.templateCategories": "Kategorie presetów",
"flow.sharedInputTest": "Testuj wejście HA",
"flow.sharedInputTesting": "Sprawdzanie…",
"flow.sharedInputTestHint": "Test używa zapisanej konfiguracji połączenia Home Assistant i nie zapisuje tego wejścia.",
"flow.sharedInputTestEntityRequired": "Podaj entity_id przed testem.",
"flow.sharedInputTestCurrent": "Aktualna wartość",
"flow.sharedInputTestExpected": "Warunek",
"flow.sharedInputTestMatched": "TRUE — warunek spełniony",
"flow.sharedInputTestNotMatched": "FALSE — warunek niespełniony",
"flow.sharedInputTestAvailable": "Encja dostępna",
"flow.sharedInputTestUnavailable": "Encja niedostępna",
"flow.sharedInputUsedBy": "Używane w {count} Flow",
"flow.sharedInputUnused": "Nieużywane w żadnym Flow",
"flow.openReferencedFlow": "Otwórz Flow „{name}”",
"flow.templateSearch": "Szukaj presetów…",
"flow.templateSearchLabel": "Szukaj presetów",
"flow.templateFavorites": "Ulubione",
"flow.templateRecent": "Ostatnie",
"flow.templateNoResults": "Brak presetów pasujących do wyszukiwania.",
"flow.templateNoFavorites": "Nie masz jeszcze ulubionych presetów.",
"flow.templateNoRecent": "Nie użyto jeszcze żadnego presetu.",
"flow.templateFavoriteAdd": "Dodaj do ulubionych",
"flow.templateFavoriteRemove": "Usuń z ulubionych",
"flow.templatePreview": "Podgląd presetu",
"flow.templatePreviewHint": "Sprawdź wymagania i układ bloków przed zastąpieniem bieżącego Flow.",
"flow.templateUse": "Użyj presetu",
"flow.templateRequirements": "Wymagania",
"flow.templateRequiresHa": "Home Assistant",
"flow.templateRequiresZone": "{count} strefa/strefy",
"flow.templateRequiresDevice": "{count} urządzenie/urządzenia",
"flow.templateRequiresGroup": "{count} grupa/grupy",
"flow.templateRequiresShared": "{count} wspólne wejście/wejścia",
"flow.templateRequiresEntity": "Encje HA skonfigurowane w presecie",
"flow.templateRequirementsReady": "Wymagania dostępne",
"flow.templateRequirementsMissing": "Brakuje: {items}",
"flow.templateNodesCount": "{count} bloków",
"flow.templateEdgesCount": "{count} połączeń",
"flow.templateSelectPreview": "Wybierz preset, aby zobaczyć podgląd.",
"flow.templateCategory.favorites": "Ulubione",
"flow.templateCategory.recent": "Ostatnie",
"flow.enable": "Włącz Flow",
"flow.disable": "Wyłącz Flow",
"flow.logLevelInfo": "Informacja",
"flow.logLevelWarn": "Ostrzeżenie",
"flow.logLevelError": "Błąd",
"flow.logKindCreated": "Utworzenie Flow",
"flow.logKindUpdated": "Aktualizacja Flow",
"flow.logKindDeleted": "Usunięcie Flow",
"flow.logKindImported": "Import Flow",
"flow.logKindDryRun": "Test Flow",
"flow.logKindConditionError": "Błąd warunku",
"flow.logKindActionSuppressed": "Akcja pominięta",
"flow.logKindActionFired": "Akcja wykonana",
"flow.logKindActionError": "Błąd akcji",
"flow.logKindBlockedZone": "Zablokowane przez strefę",
"flow.logKindBlockedManual": "Zablokowane przez sterowanie ręczne",
"flow.logKindBlockedThermostat": "Zablokowane przez termostat",
"flow.logKindBlockedTemporary": "Zablokowane przez termostat tymczasowy",
"flow.logKindBlockedThermostatOwner": "Urządzenie kontrolowane przez termostat",
"flow.logKindBlockedOwnership": "Zmiana właściciela sterowania",
"flow.logKindConflict": "Konflikt automatyzacji",
"flow.logMessageCreated": "Utworzono Flow „{name}”.",
"flow.logMessageUpdated": "Zaktualizowano Flow „{name}”.",
"flow.logMessageDeleted": "Usunięto Flow „{name}”.",
"flow.logMessageImported": "Zaimportowano Flow „{name}”.",
"flow.logMessageDryRun": "Wykonano test Flow „{name}” bez zmiany stanu urządzeń.",
"flow.logMessageConditionError": "Nie udało się poprawnie ocenić jednego z warunków Flow.",
"flow.logMessageActionSuppressed": "Akcja Flow została pominięta z powodu aktualnego właściciela sterowania.",
"flow.logMessageActionFired": "Akcja Flow została wykonana.",
"flow.logMessageActionError": "Wykonanie akcji Flow zakończyło się błędem.",
"flow.logMessageBlockedZone": "Akcja Flow została zablokowana, ponieważ powiązana strefa jest wyłączona.",
"flow.logMessageBlockedManual": "Akcja Flow została zablokowana przez ręczne sterowanie urządzeniem.",
"flow.logMessageBlockedThermostat": "Akcja Flow została zablokowana przez lokalny termostat.",
"flow.logMessageBlockedTemporary": "Akcja Flow została zablokowana przez tymczasowy termostat.",
"flow.logMessageBlockedThermostatOwner": "Akcja Flow została pominięta, ponieważ urządzeniem steruje aktywna strefa termostatu.",
"flow.logMessageBlockedOwnership": "Akcja Flow została pominięta, ponieważ w trakcie wykonania zmienił się właściciel sterowania.",
"flow.logMessageConflict": "Akcja Flow została pominięta z powodu konfliktu z inną automatyzacją dla tego samego celu."
}
}
+1
View File
@@ -103,6 +103,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
.route("/api/integrations/home-assistant/entity", post(inspect_home_assistant_entity))
.route("/api/integrations/notifications/test", post(test_notifications))
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
+24
View File
@@ -8,6 +8,30 @@ async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<Ha
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
}
#[derive(Debug, Deserialize)]
struct HaEntityRequest { entity_id: String }
async fn inspect_home_assistant_entity(State(state): State<AppState>, Json(input): Json<HaEntityRequest>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str()))
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?;
let payload = home_assistant::read_entity(&state.http, &settings.home_assistant, Some(entity_id.as_str()))
.await.map_err(|e| AppError::Device(e.to_string()))?;
let raw_state = payload.get("state").and_then(Value::as_str).unwrap_or_default().to_string();
let available = !matches!(raw_state.as_str(), "unknown" | "unavailable" | "");
Ok(Json(json!({
"ok": true,
"entity_id": entity_id,
"state": raw_state,
"available": available,
"attributes": payload.get("attributes").cloned().unwrap_or_else(|| json!({})),
"last_changed": payload.get("last_changed").cloned().unwrap_or(Value::Null),
"last_updated": payload.get("last_updated").cloned().unwrap_or(Value::Null),
})))
}
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
let old = state.settings.read().await.notifications.clone();
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
+14 -3
View File
@@ -189,7 +189,7 @@
<span class="badge" data-i18n="flow.sourceOfTruth">Flow = source of truth</span>
</div>
<section class="flow-list-section">
<div class="flow-list-heading"><div><span class="eyebrow" data-i18n="flow.listEyebrow">Twoje automatyzacje</span><h2 data-i18n="flow.listTitle">Lista Flow</h2></div><span id="flowListCount" class="badge"></span></div>
<div class="flow-list-heading flow-list-heading-count"><span id="flowListCount" class="badge"></span></div>
<div class="list-grid" id="flowList"></div>
</section>
</section>
@@ -728,6 +728,11 @@
<label><span data-i18n="common.name">Nazwa</span><input name="name" maxlength="100" required placeholder="Okno w salonie zamknięte"></label>
<label><span data-i18n="flow.sharedInputType">Typ źródła</span><select name="kind" id="flowSharedInputKind"></select></label>
<div id="flowSharedInputFields" class="flow-shared-input-fields"></div>
<div id="flowSharedInputTestPanel" class="flow-shared-test-panel" hidden>
<div><strong data-i18n="flow.sharedInputTest">Testuj wejście HA</strong><p class="field-note" data-i18n="flow.sharedInputTestHint">Test używa zapisanej konfiguracji połączenia Home Assistant i nie zapisuje tego wejścia.</p></div>
<button type="button" class="secondary" id="flowSharedInputTest" data-i18n="flow.sharedInputTest">Testuj wejście HA</button>
<div id="flowSharedInputTestResult" class="flow-shared-test-result" hidden></div>
</div>
<div class="dialog-actions flow-shared-dialog-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Anuluj</button><button type="submit" class="primary" data-i18n="actions.save">Zapisz</button></div>
</form>
</dialog>
@@ -735,8 +740,14 @@
<dialog id="flowTemplateDialog" class="flow-tool-dialog">
<div class="dialog-head"><div><span class="eyebrow" data-i18n="flow.templateLibrary">Flow library</span><h2 data-i18n="flow.templates">Templates</h2></div><button type="button" data-close>×</button></div>
<p class="field-note" data-i18n="flow.templatesHint">Ready-made layouts create an editable graph using existing zones, devices and groups.</p>
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
<div id="flowTemplateList" class="flow-template-list"></div>
<div class="flow-template-toolbar"><label><span data-i18n="flow.templateSearchLabel">Szukaj presetów</span><input id="flowTemplateSearch" type="search" autocomplete="off" data-i18n-placeholder="flow.templateSearch" placeholder="Szukaj presetów…"></label></div>
<div class="flow-template-browser">
<div class="flow-template-catalog">
<div id="flowTemplateTabs" class="flow-template-tabs" role="tablist" data-i18n-aria="flow.templateCategories" aria-label="Preset categories"></div>
<div id="flowTemplateList" class="flow-template-list"></div>
</div>
<aside id="flowTemplatePreview" class="flow-template-preview"><div class="empty compact"><strong data-i18n="flow.templatePreview">Podgląd presetu</strong><span data-i18n="flow.templateSelectPreview">Wybierz preset, aby zobaczyć podgląd.</span></div></aside>
</div>
</dialog>
<dialog id="flowTestDialog" class="flow-tool-dialog">
+170 -12
View File
@@ -57,7 +57,7 @@ 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 ? '' : 'is-disabled'}">
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><label class="flow-quick-toggle" title="${esc(tr('flow.quickToggleHint'))}"><input type="checkbox" data-flow-toggle="${esc(flow.id)}" ${flow.enabled ? 'checked' : ''}><span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></label></div>
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><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>
<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(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>
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
@@ -303,9 +303,33 @@ async function importFlowFile(file) {
}
const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced'];
const FLOW_PRESET_FAVORITES_KEY = 'gree_controller_flow_preset_favorites';
const FLOW_PRESET_RECENT_KEY = 'gree_controller_flow_preset_recent';
let flowPresetLoadPromise = null;
let flowPresetActiveCategory = '';
let flowPresetPreviewId = '';
let flowPresetSearch = '';
function flowPresetStoredIds(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
} catch (_) { return []; }
}
function flowPresetFavoriteIds() { return new Set(flowPresetStoredIds(FLOW_PRESET_FAVORITES_KEY)); }
function flowPresetRecentIds() { return flowPresetStoredIds(FLOW_PRESET_RECENT_KEY); }
function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) {} }
function toggleFlowPresetFavorite(id) {
const ids = flowPresetFavoriteIds();
if (ids.has(id)) ids.delete(id); else ids.add(id);
saveFlowPresetIds(FLOW_PRESET_FAVORITES_KEY, [...ids]);
renderFlowPresetBrowser(flowPresetActiveCategory);
}
function markFlowPresetRecent(id) {
const ids = flowPresetRecentIds().filter(value => value !== id);
ids.unshift(id);
saveFlowPresetIds(FLOW_PRESET_RECENT_KEY, ids.slice(0, 8));
}
function flowPresetText(value) {
if (typeof value === 'string') return value;
@@ -381,6 +405,84 @@ function materializeFlowPreset(preset) {
return { nodes, edges };
}
function flowPresetRequirements(preset) {
const nodes = preset?.flow?.nodes || [];
const placeholders = { zone:new Set(), device:new Set(), group:new Set(), shared:new Set() };
const visit = value => {
if (typeof value === 'string') {
const match = /^\$(zone|device|group|shared)(\d+)$/.exec(value);
if (match) placeholders[match[1]].add(value);
return;
}
if (Array.isArray(value)) value.forEach(visit);
else if (value && typeof value === 'object') Object.values(value).forEach(visit);
};
nodes.forEach(node => visit(node.config || {}));
const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(node.kind));
const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))];
const requirements = [];
const missing = [];
const addCount = (kind, count, available, key) => {
if (!count) return;
const label = tr(key, { count });
requirements.push({ label, ok:available >= count });
if (available < count) missing.push(label);
};
addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone');
addCount('device', placeholders.device.size, (app.devices || []).length, 'flow.templateRequiresDevice');
addCount('group', placeholders.group.size, (app.groups || []).length, 'flow.templateRequiresGroup');
addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared');
if (hasHa) {
const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured);
requirements.push({ label:tr('flow.templateRequiresHa'), ok:ready });
if (!ready) missing.push(tr('flow.templateRequiresHa'));
}
if (haEntities.length) requirements.push({ label:`${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok:true, info:true });
return { requirements, missing };
}
function flowPresetPreviewGraph(preset) {
const nodes = preset?.flow?.nodes || [], edges = preset?.flow?.edges || [];
if (!nodes.length) return '';
const byId = new Map(nodes.map(node => [node.id, node]));
const xs = nodes.map(node => Number(node.x || 0)), ys = nodes.map(node => Number(node.y || 0));
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
const spanX = Math.max(1, maxX - minX), spanY = Math.max(1, maxY - minY);
const point = node => ({ x:8 + ((Number(node.x || 0) - minX) / spanX) * 84, y:12 + ((Number(node.y || 0) - minY) / spanY) * 76 });
const lines = edges.map(edge => {
const from = byId.get(edge.from), to = byId.get(edge.to); if (!from || !to) return '';
const a = point(from), b = point(to);
return `<line x1="${a.x.toFixed(2)}" y1="${a.y.toFixed(2)}" x2="${b.x.toFixed(2)}" y2="${b.y.toFixed(2)}"></line>`;
}).join('');
const blocks = nodes.map(node => {
const p = point(node), meta = FLOW_NODE_META[node.kind] || { title:node.kind, category:'logic' };
return `<div class="flow-template-preview-node flow-template-preview-node-${esc(meta.category)}" style="left:${p.x.toFixed(2)}%;top:${p.y.toFixed(2)}%" title="${esc(flowNodeTitle(meta))}">${esc(flowNodeTitle(meta))}</div>`;
}).join('');
return `<div class="flow-template-preview-canvas"><svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">${lines}</svg>${blocks}</div>`;
}
function renderFlowPresetPreview(preset) {
const host = $('#flowTemplatePreview'); if (!host) return;
if (!preset) {
host.innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.templatePreview'))}</strong><span>${esc(tr('flow.templateSelectPreview'))}</span></div>`;
return;
}
const req = flowPresetRequirements(preset);
const favorite = flowPresetFavoriteIds().has(preset.id);
const reqMarkup = req.requirements.length
? req.requirements.map(item => `<span class="flow-template-requirement ${item.ok ? 'ok' : 'missing'} ${item.info ? 'info' : ''}">${item.ok ? '✓' : '!' } ${esc(item.label)}</span>`).join('')
: `<span class="flow-template-requirement ok">✓ ${esc(tr('flow.templateRequirementsReady'))}</span>`;
host.innerHTML = `<div class="flow-template-preview-head"><div><span class="eyebrow">${esc(tr('flow.templatePreview'))}</span><h3>${esc(flowPresetText(preset.name) || preset.id)}</h3></div><button type="button" class="flow-template-favorite ${favorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(favorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">★</button></div>
<p>${esc(flowPresetText(preset.description))}</p>
<div class="flow-template-preview-stats"><span>${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}</span><span>${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}</span></div>
${flowPresetPreviewGraph(preset)}
<div class="flow-template-requirements"><strong>${esc(tr('flow.templateRequirements'))}</strong><div>${reqMarkup}</div>${req.missing.length ? `<p class="field-note warning-note">${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}</p>` : ''}</div>
<div class="dialog-actions"><button type="button" class="primary" data-flow-template-use="${esc(preset.id)}">${esc(tr('flow.templateUse'))}</button></div>`;
}
function flowPresetCategoryLabel(key) { return tr(`flow.templateCategory.${key}`) === `flow.templateCategory.${key}` ? key : tr(`flow.templateCategory.${key}`); }
function flowPresetCategoryHint(key) { const name = `flow.templateCategory.${key}.hint`, value = tr(name); return value === name ? '' : value; }
function renderFlowPresetBrowser(category = '') {
const tabs = $('#flowTemplateTabs'), host = $('#flowTemplateList');
if (!tabs || !host) return;
@@ -391,20 +493,36 @@ function renderFlowPresetBrowser(category = '') {
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key).push(preset);
});
const categories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...byCategory.keys()])].filter(key => byCategory.has(key));
if (!categories.length) { tabs.innerHTML = ''; host.innerHTML = ''; return; }
flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : categories[0]);
tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(tr(`flow.templateCategory.${key}`) || key)} <span class="badge">${byCategory.get(key).length}</span></button>`).join('');
const active = byCategory.get(flowPresetActiveCategory) || [];
host.innerHTML = `<section class="flow-template-category-single"><div class="flow-template-category-head"><strong>${esc(tr(`flow.templateCategory.${flowPresetActiveCategory}`) || flowPresetActiveCategory)}</strong><span>${esc(tr(`flow.templateCategory.${flowPresetActiveCategory}.hint`) || '')}</span></div><div class="flow-template-category-grid">${active.map(preset => `<button type="button" class="flow-template-card" data-flow-template="${esc(preset.id)}"><strong>${esc(flowPresetText(preset.name) || preset.id)}</strong><span>${esc(flowPresetText(preset.description))}</span></button>`).join('')}</div></section>`;
const favorites = flowPresetFavoriteIds();
const recent = flowPresetRecentIds();
byCategory.set('favorites', presets.filter(preset => favorites.has(preset.id)));
byCategory.set('recent', recent.map(id => presets.find(preset => preset.id === id)).filter(Boolean));
const normalCategories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...presets.map(preset => preset.category || 'advanced')])].filter(key => (byCategory.get(key) || []).length);
const categories = ['favorites','recent', ...normalCategories];
if (!presets.length) { tabs.innerHTML = ''; host.innerHTML = ''; renderFlowPresetPreview(null); return; }
flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : normalCategories[0]);
tabs.innerHTML = categories.map(key => `<button type="button" class="flow-template-tab ${key === flowPresetActiveCategory ? 'active' : ''}" role="tab" aria-selected="${key === flowPresetActiveCategory}" data-flow-template-category="${esc(key)}">${esc(flowPresetCategoryLabel(key))} <span class="badge">${(byCategory.get(key) || []).length}</span></button>`).join('');
const query = flowPresetSearch.trim().toLocaleLowerCase(locale());
const activeAll = byCategory.get(flowPresetActiveCategory) || [];
const active = query ? activeAll.filter(preset => `${flowPresetText(preset.name)} ${flowPresetText(preset.description)} ${preset.id}`.toLocaleLowerCase(locale()).includes(query)) : activeAll;
const emptyKey = query ? 'flow.templateNoResults' : flowPresetActiveCategory === 'favorites' ? 'flow.templateNoFavorites' : flowPresetActiveCategory === 'recent' ? 'flow.templateNoRecent' : 'flow.templateNoResults';
const cards = active.map(preset => {
const isFavorite = favorites.has(preset.id), selected = preset.id === flowPresetPreviewId;
return `<article class="flow-template-card ${selected ? 'selected' : ''}"><button type="button" class="flow-template-card-main" data-flow-template-preview="${esc(preset.id)}"><strong>${esc(flowPresetText(preset.name) || preset.id)}</strong><span>${esc(flowPresetText(preset.description))}</span></button><button type="button" class="flow-template-favorite ${isFavorite ? 'active' : ''}" data-flow-template-favorite="${esc(preset.id)}" title="${esc(isFavorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}" aria-label="${esc(isFavorite ? tr('flow.templateFavoriteRemove') : tr('flow.templateFavoriteAdd'))}">★</button></article>`;
}).join('');
host.innerHTML = `<section class="flow-template-category-single"><div class="flow-template-category-head"><strong>${esc(flowPresetCategoryLabel(flowPresetActiveCategory))}</strong><span>${esc(flowPresetCategoryHint(flowPresetActiveCategory))}</span></div>${cards ? `<div class="flow-template-category-grid">${cards}</div>` : `<div class="empty compact"><span>${esc(tr(emptyKey))}</span></div>`}</section>`;
if (!active.some(preset => preset.id === flowPresetPreviewId)) flowPresetPreviewId = active[0]?.id || '';
renderFlowPresetPreview(presets.find(preset => preset.id === flowPresetPreviewId) || null);
}
async function openFlowTemplates() {
if (!app.flowDraft) return;
const host = $('#flowTemplateList'), tabs = $('#flowTemplateTabs');
const host = $('#flowTemplateList'), tabs = $('#flowTemplateTabs'), search = $('#flowTemplateSearch');
if (!host) return;
if (tabs) tabs.innerHTML = '';
if (search) { search.value = ''; flowPresetSearch = ''; }
host.innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`;
renderFlowPresetPreview(null);
$('#flowTemplateDialog')?.showModal();
try {
await loadFlowPresets();
@@ -415,6 +533,12 @@ async function openFlowTemplates() {
}
}
function previewFlowTemplate(key) {
const preset = (app.flowPresets || []).find(item => item.id === key); if (!preset) return;
flowPresetPreviewId = preset.id;
renderFlowPresetBrowser(flowPresetActiveCategory);
}
function applyFlowTemplate(key) {
if (!app.flowDraft) return;
const preset = (app.flowPresets || []).find(item => item.id === key);
@@ -424,9 +548,11 @@ function applyFlowTemplate(key) {
app.flowDraft.nodes = graph.nodes; app.flowDraft.edges = graph.edges;
app.flowDraft.description = flowPresetText(preset.description); app.flowSelectedNodeId = null; app.flowSelectedNodeIds = []; app.flowDirty = true;
if (!app.flowDraft.id || $('#flowName').value === tr('flow.newDefaultName')) $('#flowName').value = flowPresetText(preset.name) || preset.id;
markFlowPresetRecent(preset.id);
$('#flowTemplateDialog')?.close(); renderFlowEditor();
}
function localDateTimeInputValue(date = new Date()) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
@@ -474,13 +600,42 @@ async function runFlowDryRun() {
renderFlowDryRunResult(result);
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
}
function flowLogLevelLabel(level = '') {
const key = { info:'flow.logLevelInfo', warn:'flow.logLevelWarn', error:'flow.logLevelError' }[String(level).toLowerCase()];
return key ? tr(key) : level;
}
function flowLogKindLabel(kind = '') {
const key = {
'flow.created':'flow.logKindCreated', 'flow.updated':'flow.logKindUpdated', 'flow.deleted':'flow.logKindDeleted',
'flow.imported':'flow.logKindImported', 'flow.dry_run':'flow.logKindDryRun', 'flow.condition_error':'flow.logKindConditionError',
'flow.action_suppressed':'flow.logKindActionSuppressed', 'automation.fired':'flow.logKindActionFired',
'automation.error':'flow.logKindActionError', 'automation.blocked_by_zone':'flow.logKindBlockedZone',
'automation.blocked_by_manual_override':'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logKindBlockedThermostat',
'automation.blocked_by_temporary_thermostat':'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logKindBlockedThermostatOwner',
'automation.blocked_by_fresh_ownership':'flow.logKindBlockedOwnership', 'automation.conflict':'flow.logKindConflict',
}[kind];
return key ? tr(key) : kind;
}
function flowLogMessage(event = {}) {
const name = app.flowDraft?.name || tr('nav.flows');
const key = {
'flow.created':'flow.logMessageCreated', 'flow.updated':'flow.logMessageUpdated', 'flow.deleted':'flow.logMessageDeleted',
'flow.imported':'flow.logMessageImported', 'flow.dry_run':'flow.logMessageDryRun', 'flow.condition_error':'flow.logMessageConditionError',
'flow.action_suppressed':'flow.logMessageActionSuppressed', 'automation.fired':'flow.logMessageActionFired',
'automation.error':'flow.logMessageActionError', 'automation.blocked_by_zone':'flow.logMessageBlockedZone',
'automation.blocked_by_manual_override':'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logMessageBlockedThermostat',
'automation.blocked_by_temporary_thermostat':'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logMessageBlockedThermostatOwner',
'automation.blocked_by_fresh_ownership':'flow.logMessageBlockedOwnership', 'automation.conflict':'flow.logMessageConflict',
}[event.kind];
return key ? tr(key, { name }) : (event.message || '—');
}
async function openFlowLogs() {
if (!app.flowDraft?.id) return toast(tr('flow.saveBeforeLogs'), true);
$('#flowTestTitle').textContent = tr('flow.logs'); $('#flowSimulationControls').hidden = true; $('#flowTestResults').innerHTML = `<p class="field-note">${esc(tr('common.loading'))}</p>`; $('#flowTestDialog')?.showModal();
try {
const result = await api(`/api/flows/${encodeURIComponent(app.flowDraft.id)}/logs?limit=150`);
const events = result.events || [];
$('#flowTestResults').innerHTML = events.length ? events.map(event => `<article class="flow-log-row"><div><strong>${esc(event.kind)}</strong><span class="badge">${esc(event.level)}</span></div><p>${esc(event.message)}</p><small>${esc(new Date(event.timestamp).toLocaleString())}</small></article>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.noLogs'))}</strong></div>`;
$('#flowTestResults').innerHTML = events.length ? events.map(event => `<article class="flow-log-row"><div><strong>${esc(flowLogKindLabel(event.kind))}</strong><span class="badge">${esc(flowLogLevelLabel(event.level))}</span></div><p>${esc(flowLogMessage(event))}</p><small>${esc(new Date(event.timestamp).toLocaleString())}</small></article>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.noLogs'))}</strong></div>`;
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.logsFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
}
@@ -504,7 +659,7 @@ async function deleteFlow(id) {
async function toggleFlowEnabled(id, enabled) {
const flow = app.flows.find(item => item.id === id); if (!flow) return;
const toggle = $(`[data-flow-toggle="${CSS.escape(id)}"]`); if (toggle?.disabled) return;
const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return;
if (toggle) toggle.disabled = true;
const body = { name:flow.name, enabled, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) };
try {
@@ -597,7 +752,9 @@ document.addEventListener('click', event => {
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 templateCategory = event.target.closest?.('[data-flow-template-category]'); if (templateCategory) { renderFlowPresetBrowser(templateCategory.dataset.flowTemplateCategory); return; }
const template = event.target.closest?.('[data-flow-template]'); if (template) { applyFlowTemplate(template.dataset.flowTemplate); 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; }
const templateUse = event.target.closest?.('[data-flow-template-use]'); if (templateUse) { applyFlowTemplate(templateUse.dataset.flowTemplateUse); return; }
const remove = event.target.closest?.('[data-flow-remove]'); if (remove) { removeFlowNode(remove.dataset.flowRemove); return; }
const output = event.target.closest?.('[data-flow-output]'); if (output) { app.flowConnectFrom = output.dataset.flowOutput; renderFlowEditor(); return; }
const input = event.target.closest?.('[data-flow-input]'); if (input) { if (app.flowConnectFrom) connectFlowNodes(app.flowConnectFrom, input.dataset.flowInput); return; }
@@ -606,6 +763,7 @@ document.addEventListener('click', event => {
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 === 'import-flow') $('#flowImportFile')?.click();
else if (action === 'export-flow') exportFlow();
@@ -629,9 +787,9 @@ document.addEventListener('click', event => {
});
document.addEventListener('change', event => {
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
const toggle = event.target.closest?.('[data-flow-toggle]'); if (toggle) toggleFlowEnabled(toggle.dataset.flowToggle, toggle.checked);
});
$('#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; });
$('#flowEnabled')?.addEventListener('change', () => { if (app.flowDraft) app.flowDirty = true; });
window.addEventListener('resize', () => { if (!$('#flowEditor')?.hidden) renderFlowEdges(); });
+19 -4
View File
@@ -41,13 +41,25 @@ function sharedFlowInputSummary(item) {
try { return flowNodeSummary({ kind: item.kind, config: item.config || {} }); } catch (_) { return item.kind || '—'; }
}
function isHaSharedInputKind(kind) { return ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(kind); }
function flowSharedInputUsages(id) {
return (app.flows || []).filter(flow => (flow.nodes || []).some(node => node.kind === 'shared_input' && node.config?.input_id === id));
}
function renderFlowSharedInputs() {
const host = $('#flowSharedInputList'); if (!host) return;
const items = app.flowSharedInputs || [];
host.innerHTML = items.length ? items.map(item => `<div class="flow-shared-input-row">
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}</small><code>${esc(item.id)}</code></div>
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
</div>`).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
host.innerHTML = items.length ? items.map(item => {
const usages = flowSharedInputUsages(item.id);
const usageMarkup = usages.length
? `<div class="flow-shared-usage"><small>${esc(tr('flow.sharedInputUsedBy', { count:usages.length }))}</small><div>${usages.slice(0, 4).map(flow => `<button type="button" class="link-button" data-open-shared-flow="${esc(flow.id)}" title="${esc(tr('flow.openReferencedFlow', { name:flow.name }))}">${esc(flow.name)}</button>`).join('')}${usages.length > 4 ? `<span class="muted">+${usages.length - 4}</span>` : ''}</div></div>`
: `<small class="flow-shared-unused">${esc(tr('flow.sharedInputUnused'))}</small>`;
return `<div class="flow-shared-input-row">
<div><strong>${esc(item.name)}</strong><small>${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSummary(item))}</small><code>${esc(item.id)}</code>${usageMarkup}</div>
<div class="flow-shared-input-actions"><button type="button" class="secondary" data-flow-shared-edit="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button type="button" class="danger" data-flow-shared-delete="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div>
</div>`;
}).join('') : `<div class="empty compact"><strong>${esc(tr('flow.sharedInputsEmpty'))}</strong><span>${esc(tr('flow.sharedInputsEmptyHint'))}</span></div>`;
const revision = $('#flowSharedInputsRevision');
if (revision) revision.value = JSON.stringify(items);
}
@@ -80,6 +92,9 @@ function renderFlowSharedInputFields(kind, config = {}) {
else if (kind === 'group_state') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-shared-config="group_id">${sharedFlowOptions(app.groups, c.group_id)}</select></label><input type="hidden" data-shared-config="field" value="power_enabled">${textComparison()}`;
else if (kind === 'night_mode') fields = `<p class="field-note">${esc(tr('flow.nightModeHint'))}</p>`;
host.innerHTML = fields;
const testPanel = $('#flowSharedInputTestPanel'), testResult = $('#flowSharedInputTestResult');
if (testPanel) testPanel.hidden = !isHaSharedInputKind(kind);
if (testResult) { testResult.hidden = true; testResult.innerHTML = ''; }
}
function openFlowSharedInputEditor(id = '') {
+49
View File
@@ -300,6 +300,50 @@ $('#addFlowSharedInput')?.addEventListener('click', () => openFlowSharedInputEdi
$('#flowSharedInputKind')?.addEventListener('change', event => {
renderFlowSharedInputFields(event.target.value, flowDefaultConfig(event.target.value));
});
function compareFlowSharedHaValue(actual, operator, expected) {
if (['lt','lte','gt','gte'].includes(operator)) {
const a = Number(actual), b = Number(expected);
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
if (operator === 'lt') return a < b;
if (operator === 'lte') return a <= b;
if (operator === 'gt') return a > b;
return a >= b;
}
const left = actual == null ? '' : String(actual);
const right = expected == null ? '' : String(expected);
return operator === 'neq' ? left !== right : left === right;
}
function evaluateFlowSharedHaTest(kind, config, result) {
if (kind === 'ha_available') return { actual:result.state, matched:result.available === true };
let actual = result.state;
if (kind === 'ha_attribute') actual = result.attributes?.[config.attribute];
return { actual, matched:compareFlowSharedHaValue(actual, config.operator || 'eq', config.value) };
}
$('#flowSharedInputTest')?.addEventListener('click', async event => {
const form = $('#flowSharedInputForm'), resultHost = $('#flowSharedInputTestResult');
if (!form || !resultHost) return;
const kind = form.kind.value, config = collectFlowSharedInputConfig(kind);
const entityId = String(config.entity_id || '').trim();
if (!entityId) return toast(tr('flow.sharedInputTestEntityRequired'), true);
const button = event.currentTarget, idle = button.textContent;
button.disabled = true; button.textContent = tr('flow.sharedInputTesting');
resultHost.hidden = false; resultHost.innerHTML = `<span>${esc(tr('flow.sharedInputTesting'))}</span>`;
try {
const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } });
const evaluation = evaluateFlowSharedHaTest(kind, config, result);
const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual));
const expectation = kind === 'ha_available' ? tr('flow.sharedInputTestAvailable') : `${config.operator || 'eq'} ${config.value ?? ''}`;
resultHost.classList.toggle('pass', evaluation.matched);
resultHost.classList.toggle('fail', !evaluation.matched);
resultHost.innerHTML = `<div><strong>${esc(evaluation.matched ? tr('flow.sharedInputTestMatched') : tr('flow.sharedInputTestNotMatched'))}</strong><span class="badge ${evaluation.matched ? 'active' : ''}">${esc(result.available ? tr('flow.sharedInputTestAvailable') : tr('flow.sharedInputTestUnavailable'))}</span></div><dl><div><dt>${esc(tr('flow.sharedInputTestCurrent'))}</dt><dd><code>${esc(actual)}</code></dd></div><div><dt>${esc(tr('flow.sharedInputTestExpected'))}</dt><dd><code>${esc(expectation)}</code></dd></div></dl><small>${esc(result.entity_id)}${result.last_updated ? ` · ${esc(dateTime(result.last_updated))}` : ''}</small>`;
} catch (error) {
resultHost.classList.remove('pass'); resultHost.classList.add('fail');
resultHost.innerHTML = `<strong>${esc(tr('flow.sharedInputTestUnavailable'))}</strong><span>${esc(error.message)}</span>`;
} finally { button.disabled = false; button.textContent = idle; }
});
$('#flowSharedInputForm')?.addEventListener('submit', event => {
event.preventDefault();
const form = event.currentTarget;
@@ -318,6 +362,11 @@ $('#flowSharedInputForm')?.addEventListener('submit', event => {
document.addEventListener('click', event => {
const edit = event.target.closest?.('[data-flow-shared-edit]');
if (edit) { openFlowSharedInputEditor(edit.dataset.flowSharedEdit); return; }
const openFlow = event.target.closest?.('[data-open-shared-flow]');
if (openFlow) {
if (showView('flows')) requestAnimationFrame(() => openFlowEditor(openFlow.dataset.openSharedFlow));
return;
}
const remove = event.target.closest?.('[data-flow-shared-delete]');
if (!remove) return;
const id = remove.dataset.flowSharedDelete;
+64 -2
View File
@@ -6232,10 +6232,9 @@ body.flow-editor-open { overflow:hidden; }
.flow-list-section { margin-top:22px; padding-top:20px; border-top:1px solid var(--line); }
.flow-list-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; }
.flow-list-heading h2 { margin:3px 0 0; }
.flow-list-heading-count { justify-content:flex-end; }
.flow-card.is-disabled { opacity:.72; }
.flow-card.is-disabled:hover { opacity:.9; }
.flow-quick-toggle { display:flex; align-items:center; gap:7px; padding:7px 9px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); font-size:.78rem; cursor:pointer; white-space:nowrap; }
.flow-quick-toggle input { margin:0; }
.flow-workspace-toolbar { min-height:44px; display:flex; align-items:center; gap:7px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); }
.flow-workspace-toolbar .secondary { padding:7px 9px; font-size:.78rem; }
.flow-workspace-toolbar .badge { margin-left:2px; }
@@ -6279,3 +6278,66 @@ body.flow-editor-open { overflow:hidden; }
.flow-shared-add-button { width:auto; }
.flow-shared-dialog-actions { gap:16px; }
}
/* v0.9.10: Flow preset library preview, favorites/search and shared-input diagnostics */
.flow-shared-usage { display:grid; gap:5px; margin-top:5px; }
.flow-shared-usage > div { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.flow-shared-usage .link-button { border:0; padding:0; background:transparent; color:var(--accent); text-align:left; text-decoration:underline; text-underline-offset:2px; cursor:pointer; font:inherit; font-size:.78rem; }
.flow-shared-unused { margin-top:4px; }
.flow-shared-test-panel { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px 14px; align-items:start; padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); }
.flow-shared-test-panel .field-note { margin:3px 0 0; }
.flow-shared-test-result { grid-column:1 / -1; display:grid; gap:8px; padding:10px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); }
.flow-shared-test-result.pass { border-left:4px solid var(--green); }
.flow-shared-test-result.fail { border-left:4px solid var(--line-strong); }
.flow-shared-test-result > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; }
.flow-shared-test-result dl { display:grid; gap:5px; margin:0; }
.flow-shared-test-result dl > div { display:grid; grid-template-columns:120px minmax(0,1fr); gap:8px; }
.flow-shared-test-result dt { color:var(--muted); }
.flow-shared-test-result dd { margin:0; min-width:0; overflow-wrap:anywhere; }
.flow-shared-test-result > small { color:var(--muted); }
#flowTemplateDialog { width:min(1180px,calc(100vw - 32px)); }
.flow-template-toolbar { margin-top:14px; }
.flow-template-toolbar label { display:grid; gap:6px; }
.flow-template-toolbar input { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:10px 12px; }
.flow-template-browser { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(320px,.85fr); gap:18px; align-items:start; }
.flow-template-catalog { min-width:0; }
.flow-template-preview { position:sticky; top:0; display:grid; gap:12px; margin-top:14px; padding:14px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); min-height:250px; }
.flow-template-preview-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.flow-template-preview-head h3 { margin:3px 0 0; }
.flow-template-preview > p { margin:0; color:var(--muted); line-height:1.45; }
.flow-template-preview-stats { display:flex; flex-wrap:wrap; gap:7px; }
.flow-template-preview-stats span { padding:5px 8px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:.78rem; }
.flow-template-preview-canvas { position:relative; height:250px; overflow:hidden; border:1px solid var(--line); border-radius:12px; background:var(--surface); }
.flow-template-preview-canvas svg { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
.flow-template-preview-canvas line { stroke:var(--line-strong); stroke-width:.7; vector-effect:non-scaling-stroke; }
.flow-template-preview-node { position:absolute; transform:translate(-50%,-50%); width:104px; max-width:28%; padding:6px 7px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); box-shadow:0 3px 10px rgba(0,0,0,.12); font-size:.68rem; line-height:1.2; text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.flow-template-preview-node-sensor { border-top:3px solid var(--blue, var(--accent)); }
.flow-template-preview-node-logic { border-top:3px solid var(--accent); }
.flow-template-preview-node-action { border-top:3px solid var(--green); }
.flow-template-requirements { display:grid; gap:8px; }
.flow-template-requirements > div { display:flex; flex-wrap:wrap; gap:6px; }
.flow-template-requirement { padding:5px 8px; border:1px solid var(--line); border-radius:999px; font-size:.75rem; }
.flow-template-requirement.ok { background:var(--surface); }
.flow-template-requirement.missing { border-color:var(--danger, var(--line-strong)); }
.flow-template-requirement.info { color:var(--muted); }
.flow-template-card { position:relative; display:grid; min-height:0; padding:0; overflow:hidden; }
.flow-template-card.selected { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent-border); }
.flow-template-card-main { display:grid; gap:7px; width:100%; min-height:118px; border:0; background:transparent; color:var(--text); padding:14px 42px 14px 14px; text-align:left; align-content:start; cursor:pointer; }
.flow-template-card-main span { color:var(--muted); font-size:.86rem; line-height:1.4; }
.flow-template-favorite { width:34px; height:34px; border:0; border-radius:50%; background:transparent; color:var(--muted); cursor:pointer; font-size:1.15rem; line-height:1; }
.flow-template-card > .flow-template-favorite { position:absolute; right:7px; top:7px; }
.flow-template-favorite.active { color:var(--accent); background:var(--accent-soft); }
.flow-template-preview .dialog-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); justify-content:flex-end; }
@media (max-width:900px) {
.flow-template-browser { grid-template-columns:1fr; }
.flow-template-preview { position:static; }
}
@media (max-width:760px) {
.flow-shared-test-panel { grid-template-columns:1fr; }
.flow-shared-test-panel > button { width:100%; }
.flow-shared-test-result { grid-column:1; }
.flow-shared-test-result dl > div { grid-template-columns:1fr; gap:2px; }
.flow-template-preview-canvas { height:220px; }
}