This commit is contained in:
Mateusz Gruszczyński
2026-09-16 16:56:13 +02:00
parent 143ff0d272
commit 1862fed87f
27 changed files with 1368 additions and 178 deletions
+38 -15
View File
@@ -1,22 +1,45 @@
# Serwer HTTP
GREE_BIND=0.0.0.0:8787
GREE_DATABASE=./data/gree-controller.db
# GREE Controller 0.14.15 - standalone environment example
# HTTP / WebSocket
GREE_CONTROLLER_BIND=0.0.0.0:8787
GREE_CONTROLLER_DATABASE=./data/gree-controller.db
GREE_CONTROLLER_BASE_PATH=
RUST_LOG=gree_controller=info,tower_http=info
# Pusty token = dostęp bez logowania w zaufanej sieci LAN.
# Ustaw długi losowy token, gdy interfejs jest dostępny poza zaufanym VLAN-em.
GREE_APP_TOKEN=
# Authentication
# Empty = no login in standalone mode (trusted LAN only).
# Set a long random token when the UI/API is reachable outside a trusted LAN/VLAN.
GREE_CONTROLLER_APP_TOKEN=
# Tryb symulacji pozwala uruchomić aplikację bez klimatyzatora.
GREE_SIMULATE=true
GREE_AUTO_SEED=true
GREE_POLL_INTERVAL_SECONDS=15
GREE_ZONE_INTERVAL_SECONDS=5
GREE_DISCOVERY_TIMEOUT_MS=3000
GREE_DISCOVERY_BROADCAST=255.255.255.255:7000
# Public Custom Chart links
# Leave empty to use the current standalone origin/base path.
# Example behind a reverse proxy:
# GREE_CONTROLLER_PUBLIC_CHART_BASE_URL=https://gree.example.com
GREE_CONTROLLER_PUBLIC_CHART_BASE_URL=
# Controller / discovery
GREE_CONTROLLER_ID=gree-controller
GREE_CONTROLLER_SIMULATE=false
GREE_CONTROLLER_AUTO_SEED=false
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5
GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000
GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000
# Optional interface name or local IP used only for GREE UDP traffic.
GREE_CONTROLLER_GREE_INTERFACE=
# Opcjonalny sensor temperatury z Home Assistant.
# Compressor protection
GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED=true
GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS=180
# Optional Home Assistant connection for standalone installations.
# Not required inside the Home Assistant add-on; the add-on uses Supervisor API.
HA_URL=
HA_TOKEN=
HA_ENTITY_ID=
HA_OUTDOOR_ENTITY_ID=
HA_SENSOR_STALE_AFTER_SECONDS=300
HA_ALLOW_INVALID_TLS=false
# Do not set these manually in standalone mode:
# GREE_CONTROLLER_HA_AUTH=supervisor
# SUPERVISOR_TOKEN=...
+52 -46
View File
@@ -1,20 +1,18 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs
6c60703633b9d6a696c22beca15e2bbc0c90424a1b98a5dd53cdc0d670dffffd ./Cargo.lock
b19e576628872d7344786c71a65de6b30f67f384b9306930b1aaa6018bf2caaa ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
1066639d1459320dabdf0942fd050d55da3111ebe14216359e1c727501cdc852 ./README.md
1e25e25a50eb82291506420fe0f349a93e6e51df4a75e630bf186dacf46e8163 ./SOURCE_MANIFEST.sha256
5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs
41e8871195d9a65a2e999d9155f7a8491b6877818245d99968a13e8f0f154e67 ./docs/API.md
91e37862ce8780696128bc84ad941b27f13f2badde5748d8ba73328a8f2201af ./data/gree-controller.db
c4a46a314f328075d95ca9bf6e88f24dbc243223412c33468de0901c6d19423c ./docs/API.md
f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md
bff7a4df8138a4f7e5b7fbe94b498fde7f9785c93f7b66821650095acafea90e ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
f42e2dd9d0ad82958fd554bc8a7faf08a1bbd502f897a93cb32cc86089bda2b2 ./docs/openapi.json
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env
f809050a6074ba8e98e08c89af1174dffef679114ffae5c2d200150a9588b8d9 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
14f342bc7d1762a04df5875d32a621f06b35b06dc86ea50f1fc554f8d7b3f94d ./gree-controller.zip
cd7fd4b937a1d6138699f7effd184e3c077912b881e9c73c37b90bbafba68477 ./ha-addon/build.sh
082dfeb6326f3e1b9841ccf7f2954117ecdf0aa1a90b0aef1e6a1960dacdecbf ./ha-addon/home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./ha-addon/home-assistant/custom_components/gree_controller/__init__.py
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
fef9a676b9ce82d0539d44e456237a814169a17a158557d7b803a455985bc020 ./ha-addon/.env
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./ha-addon/home-assistant/custom_components/gree_controller/api.py
0c008329aad15f5af6328380c1afe1f49db186b37b3d1cafec4b6f4088386e8e ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./ha-addon/home-assistant/custom_components/gree_controller/config_flow.py
@@ -22,6 +20,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/hom
41a8958a6fe6d10e5679c95ac34832583a2a15ecfd67d09b4e094a83d06f5c47 ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./ha-addon/home-assistant/custom_components/gree_controller/__init__.py
2348f11fcbadc83258cdd63ec81a946a38163a7729dbcf531d93cbdd51faea9d ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
@@ -30,21 +29,24 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
034cab1bb5b34c076fe1b6ddb0cb5a2b4ae896ae830f1c054a90de58f798c749 ./ha-addon/repository/gree-controller/CHANGELOG.md
cdf3c5d60a86651353e06aad76b8fb5f8c6e0a28473b78d56334bb27fe5e1593 ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
082dfeb6326f3e1b9841ccf7f2954117ecdf0aa1a90b0aef1e6a1960dacdecbf ./ha-addon/home-assistant/README.md
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
f3e47cf1a186608f0b70d54be54ccbd1e120c968e773f31fc85ec52a03392e61 ./ha-addon/repository/gree-controller/CHANGELOG.md
40f4f7156a882ef390011915fe38ee8299633e8b51ab554cd67a7b1e4c99f4ea ./ha-addon/repository/gree-controller/config.yaml
28936708e6cfcc7eafd0d587f6d6cb50f926f70959bdfc7e07849c81d251c71a ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml
9cf50879d7ea32cd3ff7ee95a6d50c4f1eab727420bc6610282931a9117e7dde ./ha-addon/repository/gree-controller/translations/pl.yaml
ad3715c719f53dca932a208cfacbe9c65834d9552e41bfef8172bef5ce76be35 ./ha-addon/repository/gree-controller/translations/en.yaml
5564559ed847e42375ceec55e5e9f37c6c69f91b501a2d5c4e8c6656938a766d ./ha-addon/repository/gree-controller/translations/pl.yaml
2153b230357aab4766398577e23a7a9ff83bb135ddc86d1b3f2cbfc852efe0a1 ./ha-addon/repository/repository.yaml
e8a77dbdb98c6ebcfbe6a08b0b5546178d965f25496c19f7aebaff287630838e ./ha-addon/run.sh
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
ec091f3cf6745995b1a677df5b064e9ed70e67b61f2c300e146ac8d64d6af570 ./lang/en.json
b14581a982441986df693849feccfcdfe4769b245a2adac0558fc86420d17f82 ./lang/pl.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -83,8 +85,8 @@ fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoc
ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
2b0c8072501b7c63cefb16a92694132d311c0fde79bc43a5f7681d630e035b75 ./README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
0d2fd90f690d00a1f21aa4e3810a3dce976b55583df752c1cb7a90bc3742e7d3 ./scripts/api_dev_test.py
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
@@ -94,13 +96,14 @@ e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/inst
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
1a84c91a89bf7f60aa4816a7650640f315efadf39bf7b54dc20f1259b2e2d5ce ./scripts/live_realtime_test.js
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
109eb319c7aeb814f34c6388757f509a945512e4682e5d4976ba0de21a8f401b ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
28dde10947ac7a9c4b0301b67286cd01653d4d372989c1c948ec5dc39ae5dec9 ./SOURCE_MANIFEST.sha256
f6ed359370dacd691344402307843a0ad31bbeb46085f99e81cfa46a618ee87d ./src/api/assets.rs
b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
5d1cbc16bcdf0e048c9ec554bfb3a8c26068f3a99fc947ae195221015b90834c ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
@@ -110,32 +113,32 @@ fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devi
d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs
e41ddb47f46ccad81baab1d25ddba910c087350b4b521844a33eae010cdaaa0b ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
c71540b2b68e805e3a79ac787db9127b87585968a205fc307e93bf68d5b028e0 ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
cafff8c070b146619a541f7b03502e85594c2d9a442ab23610b076e6bd251abe ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs
a70ddd9d4c9fca073ff494db73ec13acc7cff1830c9675d0541518d814da3772 ./src/api.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
6acf6417b077a0204a815e255c667928476014ce5f94c1559c9d4268c00342d5 ./src/api/settings.rs
1c1fe39aec55d10608ca3db83827855b07249cd1a81ae054eb1ee6464514092b ./src/api/system.rs
9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs
dfa04f2acc0bcbfca826c4196ab960b38d6bb4193312477ca1167b190aef4b13 ./src/api/system.rs
94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs
ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs
e495bbb37e81b539bfacfce065fe791f630ccd524e2a33b9dcfd8fe6856941cf ./src/config.rs
f8394e0daca284070ac9ae0844d6b04fed7c30386f2d1ec409ecf07880bb0e6a ./src/db.rs
fdc9d019097becc5c36a0560caa1619aa35d802c0d208902b3bfecbe1643e3e8 ./src/db/climate.rs
e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/configuration.rs
46213497ae26b2f799364a2e64e41df26c93a39eb4e7167b59c1e42b525f61f7 ./src/db/core_devices.rs
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs
88cb4b7ff61a8a558949faa7157d4bc2c45facb951c57240412c4c9d3befdb86 ./src/db/events_tokens.rs
7e14cf46e6750e913f2120ccb0a75ad6884194ba973bf85fff9c05ecd919b5f0 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
7840c89515d08a23050e2634dd91bcdbf7aecc0a8a80bf5002f3eb660ca46f20 ./src/db/network_history.rs
f8394e0daca284070ac9ae0844d6b04fed7c30386f2d1ec409ecf07880bb0e6a ./src/db.rs
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
8b312e258f079d844466ad516134cdd065a017be78be41214b4239fc0f9e5b36 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
4ed54df6321496a6afabf9443beb9fa8d3434c510aa01dade318bbaa44f0f0ac ./src/engine.rs
ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs
0bf7fe7d28b4749e53ec25d71effbb78bc8f70c716714f838a4a2d8f20627d63 ./src/engine/commands.rs
f7de7b9b756bef9d6cffbc6ffdb7504b3121476f188e6ff5c8077d5fc54be811 ./src/engine/connectivity.rs
@@ -147,6 +150,7 @@ e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/g
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
99527adab9fea1caede9ebb24faa6a63d5cef4810c64c06306b571cf5cbbb0da ./src/engine/ownership.rs
1a29b7fd8de77f0a5e5964ca05e41cc5ea78855c1ab961750d40461faec305e5 ./src/engine/polling.rs
4ed54df6321496a6afabf9443beb9fa8d3434c510aa01dade318bbaa44f0f0ac ./src/engine.rs
d48ae1799e045fb212e73e630a5d26ed3c5e3359a3dac3ebd5638532efdd4285 ./src/engine/runtime.rs
c1e8de5392681ba1255e3e2d871a6ee860fb8ca987a0a8f973d9ded3977a68fb ./src/engine/schedules.rs
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
@@ -156,13 +160,12 @@ b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/t
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
3a7fada1f6dd9ebab4026b353f41b21d8ca19342b02538c711dbb2dfb599cbe7 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
94809ffbb35fded50d1252c310d3b5e088c41b75a6a061c07a93b889dfaee220 ./src/home_assistant.rs
e78ca9aff23362971eb6450534ea21bdc15150456c378ef9b226cb93a8dbc612 ./src/influxdb.rs
a1bd54026f9c7e9f84da1a730136db81095e8ba1ceeb2596fc01479def76df83 ./src/home_assistant.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
b96624fa34b218a6d5f028ba09579633f6f8a04c109f0e94801e8d89ceeeb4b5 ./src/influxdb/query.rs
e78ca9aff23362971eb6450534ea21bdc15150456c378ef9b226cb93a8dbc612 ./src/influxdb.rs
8089b8eca47419bb6b3ee02560e5cc39c00479164bc76d11992679b2cef3258e ./src/influxdb/write.rs
059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
6479dd1e5d7f8eb2ffb945ca958124107c5585c0abdfb34555367db0c21145da ./src/main.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs
06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs
@@ -170,39 +173,41 @@ fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/c
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
ba5d359e4d6c12d9f62c1b28bdfb40d312737930df5421814f5310f7183e96c1 ./src/models/history.rs
1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
f1359b68f1d041cd441f80429e77893eeb6f6194613b58fd3e7a212fb511db3e ./src/models/runtime.rs
e3dfdd6dba63f6febd0b64da413b2fe7ca258a54abc2fdd6f2982c27425abfd6 ./src/models/settings_api.rs
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs
62e696df8b86835df7ad127f03c257a78580769dc82bc66daa5041f71e8440a5 ./src/notifications.rs
cca742bb2b500eb9269f69385fcf88053cd08aabf6a98b7669b8a7a2fd67df13 ./src/protocol/crypto.rs
cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs
7907c33e6396c7e0d61705b735b21c84d6ed784012fb61022b872c6745540f1d ./src/protocol/gree/binding.rs
d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol/gree_cloud_mqtt.rs
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs
d71771855d5ac90fae4994debf53ed09472b216c17bb2155d80aa2de13c57e18 ./src/protocol/gree/commands.rs
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
05fa2e4ec133a61c23a3f4ab2ce792bbfab93feaea9f0ce0d4ccc67fc479833d ./src/protocol/gree/discovery.rs
0ab094f609edb99715402d8eeaa7e38cbfd2d271036aec787dcbb3be3e005720 ./src/protocol/gree/merge.rs
c09c931fa8d512a8883fb14f3142be694af3c51290e023d25c7c972e156e4bd2 ./src/protocol/gree/network.rs
0a901ff258a74072af3812c5417196da1a9b8daa8a2eff30c96d038558601739 ./src/protocol/gree/polling.rs
cd0612021f8ed6c9e0823d63883c0790db09aa485583f3f3fc9f2579f2c28ea3 ./src/protocol/gree.rs
50152bcad4a843ae04000b991a2365abb302fc1583ea69f09320e25740f3c38c ./src/protocol/gree/tests.rs
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
49dcca1e4a4ad858b312386e3b729f36da691916d8fe62ad682ddb86d40695b7 ./src/protocol/gree_cloud.rs
d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol/gree_cloud_mqtt.rs
6258ee3888a5f9a4f8bb6cfbbac97f2621de07f9da6bcb2fa596d452f47f2e49 ./src/protocol/mod.rs
823da603035619894d8a0252c6a9782c44fdf8d539f09a0a0c120dc8e4b8e4f2 ./src/provider.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
d051ea7cef7fc733d5fcb0a9d496c419be7ddbe799fc93842b9389d220837747 ./src/queries/maintenance.rs
e26437195696a049b50154a30574880584526c32c2a55c31ae15e261871fba05 ./src/queries/schema.rs
7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
4c88441675f8dec968e1a086e0c4514c131a15dce19801ca24c2de0b2e3086f2 ./web/custom-chart.html
f0c3b6ed0cb99a7271dbad6d4003ff98fa1a4314a384e7341441bf988b30c0ba ./web/custom-chart.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
019af8f410a20704e7445d0013fc37728d56b11b2772d679fe12a6c896972d87 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
27eff2821c40395369be15e197fa830535d2327fcb27c93a0e8e65a1c62de3ad ./web/js/bootstrap.js
5970f3d05006ab975c7c88baaed4cc73eb014fc40dbfb3f07a191db3534211ea ./web/js/charts.js
1f1b7d1fdc2755a0888a14a314668a80ed7095763eba9c821e5e0968ec54fd73 ./web/js/core.js
@@ -211,16 +216,17 @@ c5476aaa91123bc9d2b0ab1cb2870cba04fd67ae0067f86b3edb415b76cd4864 ./web/js/entit
a59fd5b822029d0c1e74ba8078f55f13122347e036a96dec383293ae386f6667 ./web/js/events.js
b08b0fe301642d1daa084b322a02e351d284d2465231505d45079a52ccb4028f ./web/js/flows.js
8e6798405332eec8439c9ea28e967ee2b1d62b57012452c03089aff01dcd6452 ./web/js/forms.js
593bba6062e39c22c8ed226ba238d02e026c451521c12aa188fcbd55c10cc64a ./web/js/history.js
c48f1947ade3ac461a8633b56bacd8be229639c53f2057f841d6ec8fc8f4e971 ./web/js/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js/main.js
6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js/realtime.js
9546ca5c5566994aff3c33d92295be46b65d51d874681764abe77470dc9c9c73 ./web/js/router.js
c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js/select-ui.js
420cc44008d739ece4031093db772cc2bd6cdc0573b971b04ccce2aedb5762dd ./web/js/settings-ui.js
9f50794a92059357301b4c920b066bdb20ce1f64fbccea42effcd196d0a00cc4 ./web/js/settings.js
420cc44008d739ece4031093db772cc2bd6cdc0573b971b04ccce2aedb5762dd ./web/js/settings-ui.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
de5fadd167f860d12c458d75c2385fca4efe8c40ab26b1e97adae455598cde16 ./web/styles.css
8f2631e2c681940773667672b1cd0e467c82c5f93a0749300ff6852bbaf669f4 ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+4 -2
View File
@@ -124,7 +124,7 @@ Environment values explicitly supplied for supported runtime overrides win over
| --- | --- | --- |
| `GREE_CONTROLLER_BIND` | `0.0.0.0:8787` | HTTP/WebSocket listen address. |
| `GREE_CONTROLLER_DATABASE` | `./data/gree-controller.db` | SQLite database path. |
| `GREE_CONTROLLER_APP_TOKEN` | empty | Optional administrator API/Web UI token. Empty means trusted-LAN mode. |
| `GREE_CONTROLLER_APP_TOKEN` | empty | Administrator API/Web UI token. Empty means trusted-LAN mode in standalone installs; with HA Supervisor auth active, empty disables direct dashboard/API access on the exposed port. |
| `GREE_CONTROLLER_BASE_PATH` | empty | Optional reverse-proxy prefix such as `/gree`. |
| `GREE_CONTROLLER_ID` | `gree-controller` | GREE client/controller identifier. |
| `GREE_CONTROLLER_SIMULATE` | `false` | Initial Simulation mode. |
@@ -283,6 +283,8 @@ There are two independent Home Assistant directions.
In the Home Assistant add-on, the connection to Home Assistant Core is automatic through the Supervisor API proxy; no Home Assistant URL or Long-Lived Access Token is required.
When the add-on detects `SUPERVISOR_TOKEN`, Home Assistant ingress remains trusted, but direct Web UI/API access on TCP `8787` requires `app_token`. If `app_token` is empty, direct dashboard/API access is disabled. History → Custom Charts → Copy link creates a random persisted share token and returns a chart-only URL. In the add-on it bypasses HA ingress and targets the same HA host/IP on the direct HTTP port (normally `8787`); in standalone mode it uses the current controller origin/base path. Only the generated chart view/data endpoint is public to the network.
For standalone installations, configure **Home Assistant / Sensors** in the Web UI or use:
```text
@@ -449,7 +451,7 @@ Swagger UI includes endpoint descriptions, authentication schemes, request/respo
examples and documented error responses. If `GREE_CONTROLLER_BASE_PATH` is configured, prefix
both paths with that base path.
Public health check:
Health check (public in standalone mode; in Supervisor mode anonymous only from the Supervisor watchdog, otherwise protected by `GREE_CONTROLLER_APP_TOKEN`):
```bash
curl http://127.0.0.1:8787/api/health
+24 -22
View File
@@ -3,11 +3,11 @@ a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
6c60703633b9d6a696c22beca15e2bbc0c90424a1b98a5dd53cdc0d670dffffd ./Cargo.lock
b19e576628872d7344786c71a65de6b30f67f384b9306930b1aaa6018bf2caaa ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
1066639d1459320dabdf0942fd050d55da3111ebe14216359e1c727501cdc852 ./README.md
2b0c8072501b7c63cefb16a92694132d311c0fde79bc43a5f7681d630e035b75 ./README.md
5302570299af2c2650bada12e123f598c5db8009949c75761385f73ed57343c5 ./build.rs
41e8871195d9a65a2e999d9155f7a8491b6877818245d99968a13e8f0f154e67 ./docs/API.md
c4a46a314f328075d95ca9bf6e88f24dbc243223412c33468de0901c6d19423c ./docs/API.md
f21d732eb1147f8f3167d2172cdf0b57e61edabdfecbaf97811d46c5bff7e47a ./docs/FLOW.md
bff7a4df8138a4f7e5b7fbe94b498fde7f9785c93f7b66821650095acafea90e ./docs/openapi.json
f42e2dd9d0ad82958fd554bc8a7faf08a1bbd502f897a93cb32cc86089bda2b2 ./docs/openapi.json
c65aa543d2e99f8f7d52736307b07ebd182597d80124661d53a16bb23f6f1118 ./ha-addon/.env.example
a18d2460340264d34c267fa704ee51a42e59bcc1783ee4c0dbb2a8ef42fa0f16 ./ha-addon/Dockerfile
0417c88a990c72aecf071fba64d5d9da65dcda4d396cd7565477f7a4532c8c5e ./ha-addon/README.md
@@ -29,14 +29,14 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/hom
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
034cab1bb5b34c076fe1b6ddb0cb5a2b4ae896ae830f1c054a90de58f798c749 ./ha-addon/repository/gree-controller/CHANGELOG.md
cdf3c5d60a86651353e06aad76b8fb5f8c6e0a28473b78d56334bb27fe5e1593 ./ha-addon/repository/gree-controller/DOCS.md
f3e47cf1a186608f0b70d54be54ccbd1e120c968e773f31fc85ec52a03392e61 ./ha-addon/repository/gree-controller/CHANGELOG.md
28936708e6cfcc7eafd0d587f6d6cb50f926f70959bdfc7e07849c81d251c71a ./ha-addon/repository/gree-controller/DOCS.md
c3db302f38d26dc9f8caf37a1a8a07a1334cc07ae301d452f2940a5ec5915719 ./ha-addon/repository/gree-controller/README.md
40f4f7156a882ef390011915fe38ee8299633e8b51ab554cd67a7b1e4c99f4ea ./ha-addon/repository/gree-controller/config.yaml
3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
d13e4c2b993d209078342c0c86d852b300692089b377aa044f811308dd188a1e ./ha-addon/repository/gree-controller/translations/en.yaml
9cf50879d7ea32cd3ff7ee95a6d50c4f1eab727420bc6610282931a9117e7dde ./ha-addon/repository/gree-controller/translations/pl.yaml
ad3715c719f53dca932a208cfacbe9c65834d9552e41bfef8172bef5ce76be35 ./ha-addon/repository/gree-controller/translations/en.yaml
5564559ed847e42375ceec55e5e9f37c6c69f91b501a2d5c4e8c6656938a766d ./ha-addon/repository/gree-controller/translations/pl.yaml
2153b230357aab4766398577e23a7a9ff83bb135ddc86d1b3f2cbfc852efe0a1 ./ha-addon/repository/repository.yaml
e8a77dbdb98c6ebcfbe6a08b0b5546178d965f25496c19f7aebaff287630838e ./ha-addon/run.sh
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
@@ -97,9 +97,9 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
109eb319c7aeb814f34c6388757f509a945512e4682e5d4976ba0de21a8f401b ./src/api.rs
3dfd1e053cbfa0d684dd88cf99b244a96a36e2ff23d2a0715b8327ab62258254 ./src/api/assets.rs
05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs
a70ddd9d4c9fca073ff494db73ec13acc7cff1830c9675d0541518d814da3772 ./src/api.rs
f6ed359370dacd691344402307843a0ad31bbeb46085f99e81cfa46a618ee87d ./src/api/assets.rs
b24092f34a46626d64628b510eba82193f0b203fd097f6a621b91af5d91eb571 ./src/api/auth.rs
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
5d1cbc16bcdf0e048c9ec554bfb3a8c26068f3a99fc947ae195221015b90834c ./src/api/configuration.rs
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
@@ -109,16 +109,16 @@ fcbb3da0538aab451f71810f829cc248d6bb67e586dc418d71a572980583823d ./src/api/devi
d59b080b392b574ec94276c7b485b455d0d1abacc7de2ef29dcbfcf7d86dbdb0 ./src/api/flows.rs
2cfee7029f065e81628a8942e3dad00a03c3a5cf4f8492d07f12b75d05c07359 ./src/api/gree_cloud.rs
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
376ac63794378c427d798e853a060612e76a95c9aa0357c2f216ba9e4f769f2f ./src/api/history.rs
e41ddb47f46ccad81baab1d25ddba910c087350b4b521844a33eae010cdaaa0b ./src/api/history.rs
e8edfbb725d6de650442444b0a929ad2be0bd2c2d7d227d2b3fdd99d05c94f94 ./src/api/house.rs
c71540b2b68e805e3a79ac787db9127b87585968a205fc307e93bf68d5b028e0 ./src/api/integrations.rs
6bfbe5135d4f93bd8d19a707877b53ed2029fd8490b4b1954a7520888fb1d664 ./src/api/middleware.rs
cafff8c070b146619a541f7b03502e85594c2d9a442ab23610b076e6bd251abe ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
6acf6417b077a0204a815e255c667928476014ce5f94c1559c9d4268c00342d5 ./src/api/settings.rs
1c1fe39aec55d10608ca3db83827855b07249cd1a81ae054eb1ee6464514092b ./src/api/system.rs
9118a5a3a381b010e22d646498ff56d2e05f51656789682cc4371b09c384aa98 ./src/api/websocket.rs
dfa04f2acc0bcbfca826c4196ab960b38d6bb4193312477ca1167b190aef4b13 ./src/api/system.rs
94d3845def693aafe15fa6e8b390881c46c3c236b62f247ddfdc055d9226ed22 ./src/api/websocket.rs
ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs
e495bbb37e81b539bfacfce065fe791f630ccd524e2a33b9dcfd8fe6856941cf ./src/config.rs
f8394e0daca284070ac9ae0844d6b04fed7c30386f2d1ec409ecf07880bb0e6a ./src/db.rs
@@ -127,12 +127,12 @@ e5d2027d40a99e61c2c572499af617a92de9e554f8916f3ea6f6731d1d6b09b4 ./src/db/confi
46213497ae26b2f799364a2e64e41df26c93a39eb4e7167b59c1e42b525f61f7 ./src/db/core_devices.rs
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
f99de2230082f17eafaf03f5361bb67419205a23d64f22480c996f9c32c55ac5 ./src/db/energy_history.rs
88cb4b7ff61a8a558949faa7157d4bc2c45facb951c57240412c4c9d3befdb86 ./src/db/events_tokens.rs
7e14cf46e6750e913f2120ccb0a75ad6884194ba973bf85fff9c05ecd919b5f0 ./src/db/events_tokens.rs
bb5048ee60053f39ce3c1203dc1f24c7047310538d2357f105a052c016d21ec4 ./src/db/flows.rs
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
7840c89515d08a23050e2634dd91bcdbf7aecc0a8a80bf5002f3eb660ca46f20 ./src/db/network_history.rs
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
a76938b2e49719b97f8abf40a5cce03df6740ce2e8ac138b179c761619b32ce6 ./src/db/tests.rs
8b312e258f079d844466ad516134cdd065a017be78be41214b4239fc0f9e5b36 ./src/db/tests.rs
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
4ed54df6321496a6afabf9443beb9fa8d3434c510aa01dade318bbaa44f0f0ac ./src/engine.rs
ed5c9a0c61c6e597acb33f58593ebe02b562ddc68fa61271e1c1cd436c1003d3 ./src/engine/automations.rs
@@ -155,12 +155,12 @@ b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/t
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
3a7fada1f6dd9ebab4026b353f41b21d8ca19342b02538c711dbb2dfb599cbe7 ./src/engine/zone_control.rs
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
94809ffbb35fded50d1252c310d3b5e088c41b75a6a061c07a93b889dfaee220 ./src/home_assistant.rs
a1bd54026f9c7e9f84da1a730136db81095e8ba1ceeb2596fc01479def76df83 ./src/home_assistant.rs
e78ca9aff23362971eb6450534ea21bdc15150456c378ef9b226cb93a8dbc612 ./src/influxdb.rs
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
b96624fa34b218a6d5f028ba09579633f6f8a04c109f0e94801e8d89ceeeb4b5 ./src/influxdb/query.rs
8089b8eca47419bb6b3ee02560e5cc39c00479164bc76d11992679b2cef3258e ./src/influxdb/write.rs
059346de38c710fadf301b467c0eb45dcc2f764561b868a155c2e089a5027422 ./src/main.rs
6479dd1e5d7f8eb2ffb945ca958124107c5585c0abdfb34555367db0c21145da ./src/main.rs
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
920e028d96a14cca9f74a964bb296e68a109cfead314d3164c180201e606d151 ./src/models/automation.rs
fc5f2802a073a175a8985e56bcb87eb1ff481226d5b4eeabad73dbba1839fc45 ./src/models/control_plan.rs
@@ -193,12 +193,14 @@ d406a2c5a49fe059f3f3a00ea534a32f9bdd972c09cc051cbfc4317f7c91b38c ./src/protocol
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
d051ea7cef7fc733d5fcb0a9d496c419be7ddbe799fc93842b9389d220837747 ./src/queries/maintenance.rs
e26437195696a049b50154a30574880584526c32c2a55c31ae15e261871fba05 ./src/queries/schema.rs
7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs
7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
7e7037a059b585c5709367b9431a225789e7039796e3ed19fc132776ac234099 ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
4c88441675f8dec968e1a086e0c4514c131a15dce19801ca24c2de0b2e3086f2 ./web/custom-chart.html
f0c3b6ed0cb99a7271dbad6d4003ff98fa1a4314a384e7341441bf988b30c0ba ./web/custom-chart.js
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
019af8f410a20704e7445d0013fc37728d56b11b2772d679fe12a6c896972d87 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
@@ -210,7 +212,7 @@ c5476aaa91123bc9d2b0ab1cb2870cba04fd67ae0067f86b3edb415b76cd4864 ./web/js/entit
a59fd5b822029d0c1e74ba8078f55f13122347e036a96dec383293ae386f6667 ./web/js/events.js
b08b0fe301642d1daa084b322a02e351d284d2465231505d45079a52ccb4028f ./web/js/flows.js
8e6798405332eec8439c9ea28e967ee2b1d62b57012452c03089aff01dcd6452 ./web/js/forms.js
593bba6062e39c22c8ed226ba238d02e026c451521c12aa188fcbd55c10cc64a ./web/js/history.js
c48f1947ade3ac461a8633b56bacd8be229639c53f2057f841d6ec8fc8f4e971 ./web/js/history.js
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js/main.js
6bb9386707dcf601111fea15a1a366d8798313b973ca1624878618f4c3095add ./web/js/navigation.js
799613c6d465cb2f92ecb57cb082d0c33729a03d88ab4ca7fa12a3cbd7e38e75 ./web/js/realtime.js
@@ -220,6 +222,6 @@ c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js/selec
9f50794a92059357301b4c920b066bdb20ce1f64fbccea42effcd196d0a00cc4 ./web/js/settings.js
3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
de5fadd167f860d12c458d75c2385fca4efe8c40ab26b1e97adae455598cde16 ./web/styles.css
e25c0f0419611c47a1870d0f37b5db1fe5632c12eeba0b3556179e13f222e02a ./web/styles.css
fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+16 -3
View File
@@ -38,7 +38,7 @@ There are three access levels.
### Public
No token is required for:
No administrator token is required for the static UI shell/assets and the generated chart share surface listed below. In Supervisor mode, `/api/health` is anonymous only to the Supervisor watchdog; a direct network request needs the application token.
```text
GET /api/health
@@ -55,11 +55,13 @@ GET /sw.js
GET /favicon.svg
GET /lang/index.json
GET /lang/{file}
GET /charts/custom/{share-token}
GET /api/public/charts/custom/{share-token}
```
### Administrator API
All normal `/api/*` routes are administrator routes. If `GREE_CONTROLLER_APP_TOKEN` is empty, the controller intentionally operates in trusted-LAN mode and these routes do not require authentication.
All normal `/api/*` routes are administrator routes. In standalone installations, an empty `GREE_CONTROLLER_APP_TOKEN` keeps trusted-LAN mode. When Home Assistant Supervisor authentication is active, direct requests to the administrator API require `GREE_CONTROLLER_APP_TOKEN`; if it is empty, direct administrator access is disabled. Requests proxied by the trusted Home Assistant ingress are accepted without the application token.
When an app token is configured, send either:
@@ -119,6 +121,9 @@ Common statuses:
| Method | Endpoint | Description |
| --- | --- | --- |
| GET | `/api/health` | Lightweight process/control-engine health. |
| POST | `/api/charts/custom/share` | Create a persisted Custom Chart share; administrator/ingress authentication required. |
| GET | `/api/public/charts/custom/{token}` | Read-only data for one generated Custom Chart share. |
| GET | `/charts/custom/{token}` | Standalone chart-only HTML view. |
| GET | `/api/bootstrap` | Complete initial application snapshot. |
| GET | `/api/system/info` | Runtime/system diagnostic information. |
| GET | `/ws` | Live WebSocket event stream. |
@@ -226,7 +231,7 @@ Common statuses:
### `GET /api/health`
Public lightweight health check.
Lightweight health check. It is public in standalone mode. When Supervisor authentication is active, a request without the application token is accepted only from the Supervisor peer so the add-on watchdog continues to work.
Response:
@@ -316,6 +321,14 @@ Returns the initial Web UI snapshot:
`settings` is a single startup snapshot composed from the same response models as the eight `/api/settings/*` GET endpoints. Secret values are never included; only `*_configured` flags are exposed for stored credentials. The split settings endpoints remain the canonical resources for independent reads and updates.
### Custom Chart share links
`POST /api/charts/custom/share` is an administrator endpoint that persists a selected Custom Chart definition and returns a random path such as `/charts/custom/chart_<token>`. Only a hash of the share token is stored. The URL does not contain device names or metric selectors.
`GET /charts/custom/:token` renders only the shared chart, without the dashboard. `GET /api/public/charts/custom/:token` returns only the series configured for that share and is intentionally unauthenticated. Possession of the unguessable share URL is the authorization for this narrow read-only endpoint.
In Home Assistant add-on ingress, the Web UI builds the copied URL against the direct add-on host and bound HTTP port (normally `8787`) instead of the `/api/hassio_ingress/...` prefix. Standalone installations use their current origin and configured base path.
### `GET /api/system/info`
Returns the `system` diagnostic object independently of the full bootstrap. Useful for monitoring and **Settings → System status**.
+174 -1
View File
@@ -104,7 +104,7 @@
"System"
],
"summary": "Health check",
"description": "Public lightweight process and control-engine health check. `control_ready=false` means the process is alive but initial physical device synchronization has not completed.",
"description": "Lightweight process and control-engine health check. In standalone mode it is public. When Home Assistant Supervisor authentication is active, anonymous health requests are accepted only from the Supervisor peer for the add-on watchdog; direct network requests require the administrator app token. `control_ready=false` means the process is alive but initial physical device synchronization has not completed.",
"operationId": "health",
"security": [],
"responses": {
@@ -5591,6 +5591,179 @@
}
}
}
},
"/api/charts/custom/share": {
"post": {
"tags": [
"History"
],
"summary": "Create Custom Chart share",
"description": "Persists a validated Custom Chart definition and returns an opaque random chart-only path. The share token is returned only in the URL; SQLite stores its SHA-256 hash.",
"operationId": "createPublicCustomChart",
"security": [
{
"BearerToken": []
},
{
"ApiTokenHeader": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"series"
],
"properties": {
"title": {
"type": [
"string",
"null"
],
"maxLength": 120
},
"series": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"items": {
"type": "string",
"maxLength": 256
}
},
"hours": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 87600,
"default": 24
},
"lang": {
"type": [
"string",
"null"
],
"enum": [
"en",
"pl",
null
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "Generated chart-only share path",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"example": "/charts/custom/chart_example"
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/api/public/charts/custom/{token}": {
"get": {
"tags": [
"History"
],
"summary": "Read shared Custom Chart data",
"description": "Read-only public data endpoint for exactly one persisted Custom Chart share. Possession of the unguessable share URL is the authorization; arbitrary metric selectors are not accepted.",
"operationId": "publicCustomChart",
"security": [],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string",
"pattern": "^chart_"
}
}
],
"responses": {
"200": {
"description": "Chart definition and selected time-series points",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
},
"404": {
"$ref": "#/components/responses/NotFound"
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/charts/custom/{token}": {
"get": {
"tags": [
"History"
],
"summary": "Open standalone shared Custom Chart",
"description": "Returns a chart-only HTML page without the application dashboard. The page loads data only through the matching opaque share token.",
"operationId": "publicCustomChartPage",
"security": [],
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string",
"pattern": "^chart_"
}
}
],
"responses": {
"200": {
"description": "Standalone Custom Chart HTML page",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {
@@ -2,6 +2,10 @@
## 0.14.14
- Changes History → Custom Charts links to open a standalone chart-only page instead of the full dashboard.
- Persists chart shares behind random bearer URLs whose token is stored only as a hash; shared URLs do not expose metric/device selectors.
- In Supervisor mode, requires `app_token` for direct dashboard/API/WebSocket access on port `8787`, while trusted HA ingress remains automatic.
- Keeps only generated Custom Chart data public to the network; the add-on watchdog may call `/api/health` anonymously only from the Supervisor peer.
- Makes the Home Assistant entity picker in `Reusable data -> Shared Flow input` explicit with a separate search box and selected `entity_id` field.
- Filters the live Home Assistant entity catalog by friendly name, `entity_id`, current state, unit and device class while typing.
- Keeps automatic Supervisor authentication, failed-test manual fallback and standalone Home Assistant behavior unchanged.
+4 -4
View File
@@ -55,7 +55,7 @@ Broadcast zwykle nie przechodzi przez router. Sterowanie unicast może działać
| `poll_interval_seconds` | interwał odpytywania urządzeń |
| `zone_interval_seconds` | interwał sterowania strefami/termostatem |
| `discovery_timeout_ms` | timeout discovery UDP |
| `app_token` | opcjonalna ochrona bezpośredniego Web UI/API |
| `app_token` | token bezpośredniego Web UI/API; w trybie Supervisor pusty = direct access zablokowany |
| `log_level` | `error`, `warn`, `info`, `debug`, `trace` |
### Home Assistant API
@@ -64,7 +64,7 @@ Dodatek korzysta automatycznie z wewnętrznego proxy Home Assistant Core (`http:
### Dostęp, dane i bezpieczeństwo
Usługa słucha na TCP `8787`; ingress Home Assistant przekazuje Web UI na ten port. Jeżeli `8787` jest osiągalny z niezaufanej sieci, ustaw `app_token` albo zablokuj port firewallem. Baza jest zapisywana w `/data/gree-controller.db`; konfiguracja używa `backup: cold`, więc dane są objęte backupem dodatku.
Usługa słucha na TCP `8787`; ingress Home Assistant przekazuje Web UI na ten port. Gdy wykryty jest `SUPERVISOR_TOKEN`, bezpośredni dashboard/API na `:8787` wymaga `app_token`; przy pustym `app_token` bezpośredni dashboard jest zablokowany. Z sieci bez poświadczeń dostępny jest tylko publiczny widok i endpoint danych pojedynczego Custom Chart (`/charts/custom/<share-token>`, `/api/public/charts/custom/<share-token>`). Token udostępnienia jest losowy, jego skrót jest zapisywany w SQLite, a sam URL nie zawiera nazw urządzeń ani listy metryk. Link kopiowany z History → Custom chart omija HA ingress i wskazuje bezpośrednio host/IP dodatku na jego porcie HTTP (domyślnie `8787`). `/api/health` bez tokenu jest akceptowane tylko od peera Supervisora na potrzeby watchdoga; bezpośrednie żądanie sieciowe wymaga `app_token`. Baza jest zapisywana w `/data/gree-controller.db`; konfiguracja używa `backup: cold`, więc dane są objęte backupem dodatku.
Watchdog sprawdza `/api/health`. Do diagnostyki sieci najpierw sprawdź `ha network info`, poprawność `gree_interface`, broadcast konkretnej podsieci i reguły UDP/firewalla.
@@ -120,7 +120,7 @@ Broadcast normally does not cross routers. Unicast control may work through rout
| `poll_interval_seconds` | device polling interval |
| `zone_interval_seconds` | thermostat/zone control interval |
| `discovery_timeout_ms` | UDP discovery timeout |
| `app_token` | optional protection for direct Web UI/API access |
| `app_token` | token for direct Web UI/API access; in Supervisor mode empty = direct access disabled |
| `log_level` | `error`, `warn`, `info`, `debug`, `trace` |
### Home Assistant API
@@ -129,6 +129,6 @@ The add-on automatically uses the internal Home Assistant Core proxy (`http://su
### Access, data and security
The service listens on TCP `8787`; Home Assistant ingress proxies the Web UI to that port. If `8787` is reachable from an untrusted network, configure `app_token` or block the port at the firewall. The database is stored in `/data/gree-controller.db`; `backup: cold` keeps it in the add-on backup.
The service listens on TCP `8787`; Home Assistant ingress proxies the Web UI to that port. When `SUPERVISOR_TOKEN` is detected, direct dashboard/API access on `:8787` requires `app_token`; with an empty `app_token`, direct dashboard access is disabled. From the network, the only unauthenticated application data is the single public Custom Chart view/data endpoint (`/charts/custom/<share-token>`, `/api/public/charts/custom/<share-token>`). The share token is random, only its hash is stored in SQLite, and the URL does not expose device names or metric selectors. Links copied from History → Custom chart bypass HA ingress and point directly to the add-on host/IP on its HTTP port (default `8787`). `/api/health` is accepted without a token only from the Supervisor peer for the add-on watchdog; direct network requests require `app_token`. The database is stored in `/data/gree-controller.db`; `backup: cold` keeps it in the add-on backup.
The watchdog checks `/api/health`. For network troubleshooting, verify `ha network info`, `gree_interface`, the selected subnet broadcast, and UDP/firewall rules first.
@@ -22,7 +22,7 @@ configuration:
description: UDP discovery timeout in milliseconds.
app_token:
name: Application token
description: Optional token protecting direct Web UI/API access outside Home Assistant ingress.
description: Token required for direct dashboard/API access on port 8787. With SUPERVISOR_TOKEN active, an empty token disables direct access; only generated Custom Chart share links remain public.
log_level:
name: Log level
description: Controller log verbosity.
@@ -22,7 +22,7 @@ configuration:
description: Timeout wykrywania UDP w milisekundach.
app_token:
name: Token aplikacji
description: Opcjonalna ochrona bezpośredniego dostępu do Web UI/API poza ingressem Home Assistanta.
description: Token wymagany do bezpośredniego dostępu do dashboardu/API na porcie 8787. Przy aktywnym SUPERVISOR_TOKEN pusty token blokuje direct access; publiczne pozostają wyłącznie wygenerowane linki Custom Chart.
log_level:
name: Poziom logowania
description: Szczegółowość logów kontrolera.
+8 -2
View File
@@ -23,7 +23,7 @@ use axum::{
body::Body,
extract::{
ws::{Message, WebSocket},
Path, Query, Request, State, WebSocketUpgrade,
ConnectInfo, Path, Query, Request, State, WebSocketUpgrade,
},
http::{header, HeaderMap, HeaderValue, StatusCode},
middleware::{self, Next},
@@ -39,7 +39,7 @@ use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::{
net::IpAddr,
net::{IpAddr, SocketAddr},
sync::atomic::Ordering,
time::{Duration, Instant},
};
@@ -49,6 +49,8 @@ use uuid::Uuid;
mod openapi;
const INDEX_HTML: &str = include_str!("../web/index.html");
const CUSTOM_CHART_HTML: &str = include_str!("../web/custom-chart.html");
const CUSTOM_CHART_JS: &str = include_str!("../web/custom-chart.js");
const NOT_FOUND_HTML: &str = include_str!("../web/404.html");
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js"));
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
@@ -156,6 +158,7 @@ pub fn router(state: AppState) -> Router {
)
.route("/api/readings", get(readings))
.route("/api/history", get(history))
.route("/api/charts/custom/share", post(create_public_custom_chart))
.route("/api/history/energy", get(energy_history))
.route("/api/history/network", get(network_history))
.route("/api/control-plan", get(control_plan))
@@ -300,6 +303,9 @@ pub fn router(state: AppState) -> Router {
let mut app = Router::new()
.route("/api/health", get(health))
.route("/api/public/charts/custom/:token", get(public_custom_chart))
.route("/charts/custom/:token", get(custom_chart_page))
.route("/custom-chart.js", get(custom_chart_js))
.route("/ws", get(websocket))
.route("/", get(index))
.route("/index.html", get(index))
+35
View File
@@ -29,6 +29,34 @@ async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Respons
response
}
async fn custom_chart_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
let base = request_base_path(&state, &headers);
let body = CUSTOM_CHART_HTML
.replace("__GREE_BASE_PATH__", &base)
.replace(
"__GREE_THEME_INIT_ASSET__",
&format!("{base}{THEME_INIT_ASSET_PATH}"),
)
.replace(
"__GREE_STYLES_ASSET__",
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
)
.replace(
"__GREE_CUSTOM_CHART_ASSET__",
&format!("{base}/custom-chart.js"),
);
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, no-cache"),
);
response
}
async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
let base = request_base_path(&state, &headers);
let body = INDEX_HTML
@@ -88,6 +116,13 @@ fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
}
None
}
async fn custom_chart_js() -> Response {
static_response(
CUSTOM_CHART_JS,
"application/javascript; charset=utf-8",
"public, max-age=300",
)
}
async fn app_js() -> Response {
static_response(
APP_JS,
+24
View File
@@ -22,12 +22,36 @@ async fn debug_api_requests(
response
}
fn trusted_home_assistant_ingress_parts(headers: &HeaderMap, peer: IpAddr) -> bool {
let ingress_request = headers
.get("x-ingress-path")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.is_some_and(|path| path.starts_with("/api/hassio_ingress/"));
ingress_request && home_assistant::is_supervisor_ingress_peer(peer)
}
fn trusted_home_assistant_ingress(request: &Request) -> bool {
request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| trusted_home_assistant_ingress_parts(request.headers(), info.0.ip()))
.unwrap_or(false)
}
async fn auth(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, AppError> {
if trusted_home_assistant_ingress(&request) {
return Ok(next.run(request).await);
}
let expected = state.config.app_token.trim();
if home_assistant::supervisor_token_detected() && expected.is_empty() {
return Err(AppError::Unauthorized);
}
if expected.is_empty() {
return Ok(next.run(request).await);
}
+268
View File
@@ -495,6 +495,274 @@ async fn history(
}
}
#[derive(Debug, Deserialize)]
struct CreatePublicCustomChartRequest {
title: Option<String>,
series: Vec<String>,
hours: Option<i64>,
lang: Option<String>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct PublicCustomChartShare {
title: String,
series: Vec<String>,
hours: i64,
lang: String,
}
#[derive(Debug, serde::Serialize)]
struct PublicChartPoint {
timestamp: chrono::DateTime<Utc>,
value: f64,
}
#[derive(Debug, serde::Serialize)]
struct PublicChartSeries {
key: String,
label: String,
dashed: bool,
points: Vec<PublicChartPoint>,
}
fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
if series.is_empty()
|| series.len() > 16
|| series
.iter()
.any(|item| item.is_empty() || item.len() > 256)
{
return Err(AppError::BadRequest("invalid custom chart definition".into()));
}
for key in series {
let mut parts = key.splitn(3, '|');
let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default();
if id.trim().is_empty() || public_chart_field_label(kind, field, "en").is_none() {
return Err(AppError::BadRequest(format!(
"unsupported custom chart series: {key}"
)));
}
}
Ok(())
}
fn generate_public_chart_token() -> String {
let mut bytes = [0u8; 32];
let mut rng = OsRng;
rng.fill_bytes(&mut bytes);
format!("chart_{}", URL_SAFE_NO_PAD.encode(bytes))
}
async fn create_public_custom_chart(
State(state): State<AppState>,
Json(input): Json<CreatePublicCustomChartRequest>,
) -> Result<Json<Value>, AppError> {
validate_public_chart_spec(&input.series)?;
let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650);
let lang = if input.lang.as_deref() == Some("pl") {
"pl"
} else {
"en"
};
let default_title = if lang == "pl" {
"Wykres niestandardowy"
} else {
"Custom chart"
};
let title = input
.title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_title)
.chars()
.take(120)
.collect::<String>();
let share = PublicCustomChartShare {
title,
series: input.series,
hours,
lang: lang.to_string(),
};
let token = generate_public_chart_token();
let token_hash = hash_token(&token);
state
.db
.save_public_chart_share(&token_hash, &serde_json::to_value(&share)?)?;
Ok(Json(json!({
"path": format!("/charts/custom/{token}")
})))
}
fn public_chart_field_label(kind: &str, field: &str, lang: &str) -> Option<&'static str> {
let pl = lang == "pl";
match (kind, field) {
("device", "indoor") => Some(if pl { "Temperatura wewnętrzna" } else { "Indoor temperature" }),
("device", "outdoor") => Some(if pl { "Temperatura zewnętrzna GREE" } else { "GREE outdoor temperature" }),
("device", "target") => Some(if pl { "Temperatura zadana urządzenia" } else { "Device target" }),
("installation", "outdoor") => Some(if pl { "Wspólna temperatura zewnętrzna" } else { "Shared outdoor temperature" }),
("zone", "control") => Some(if pl { "Temperatura sterująca" } else { "Control temperature" }),
("zone", "gree") => Some(if pl { "Czujnik GREE" } else { "GREE sensor" }),
("zone", "external") => Some(if pl { "Czujnik pomieszczenia" } else { "Room sensor" }),
("zone", "target") => Some(if pl { "Temperatura docelowa" } else { "Comfort target" }),
("zone", "device_target") => Some(if pl { "Nastawa urządzenia" } else { "Device setpoint" }),
("zone", "outdoor") => Some(if pl { "Temperatura zewnętrzna" } else { "Outdoor temperature" }),
("ha", "temperature") => Some(if pl { "Temperatura" } else { "Temperature" }),
_ => None,
}
}
fn reading_points(rows: Vec<Reading>, field: &str) -> Vec<PublicChartPoint> {
rows.into_iter()
.filter_map(|row| {
let value = match field {
"indoor" => row.indoor_temperature,
"outdoor" => row.outdoor_temperature,
"target" => Some(row.target_temperature),
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
})
.collect()
}
fn zone_reading_points(rows: Vec<ZoneReading>, field: &str) -> Vec<PublicChartPoint> {
rows.into_iter()
.filter_map(|row| {
let value = match field {
"control" => row.control_temperature,
"gree" => row.gree_temperature,
"external" => row.external_temperature,
"target" => row.target_temperature,
"device_target" => row.device_setpoint,
"outdoor" => row.outdoor_temperature,
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
})
.collect()
}
async fn public_custom_chart(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Result<Json<Value>, AppError> {
if token.len() < 32 || token.len() > 128 || !token.starts_with("chart_") {
return Err(AppError::NotFound("custom chart".into()));
}
let payload = state
.db
.get_public_chart_share(&hash_token(&token))?
.ok_or_else(|| AppError::NotFound("custom chart".into()))?;
let share: PublicCustomChartShare = serde_json::from_value(payload)?;
validate_public_chart_spec(&share.series)?;
let hours = share.hours.clamp(1, 24 * 3650);
let lang = if share.lang == "pl" { "pl" } else { "en" };
let since = Utc::now() - ChronoDuration::hours(hours);
let bucket_seconds = history_bucket_seconds(hours);
let limit = 20_000;
let ha_settings = state.settings.read().await.home_assistant.clone();
let outdoor_entity = ha_settings.outdoor_entity_id.clone();
let mut series = Vec::with_capacity(share.series.len());
for key in share.series {
let mut parts = key.splitn(3, '|');
let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default();
let field_label = public_chart_field_label(kind, field, lang)
.ok_or_else(|| AppError::BadRequest(format!("unsupported custom chart series: {key}")))?;
let item = match kind {
"device" => {
let device = state.db.get_device(id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
let (rows, _, _) = combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", device.name, field_label),
dashed: field == "target",
points: reading_points(rows, field),
}
}
"installation" => {
let group = state.db.get_device_group(id)?
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let representative = group.outdoor_temperature_device_id.as_deref()
.filter(|device_id| group.device_ids.iter().any(|member| member.as_str() == *device_id))
.or_else(|| group.device_ids.first().map(String::as_str))
.ok_or_else(|| AppError::BadRequest(format!("device group {id} has no devices")))?;
let (rows, _, _) = combined_device_history(&state, Some(representative), since.clone(), bucket_seconds, limit).await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", group.name, field_label),
dashed: false,
points: reading_points(rows, "outdoor"),
}
}
"zone" => {
let zone = state.db.get_zone(id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let (rows, _, _) = combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", zone.name, field_label),
dashed: matches!(field, "target" | "device_target" | "outdoor"),
points: zone_reading_points(rows, field),
}
}
"ha" => {
let (rows, _, _) = combined_sensor_history(
&state,
Some(id),
since.clone(),
bucket_seconds,
limit,
&outdoor_entity,
).await?;
let alias = ha_settings.sensor_aliases.get(id)
.map(String::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(id);
PublicChartSeries {
key: key.clone(),
label: format!("HA · {} · {}", alias, field_label),
dashed: true,
points: rows.into_iter()
.filter(|row| row.entity_id == id && row.temperature.is_finite())
.map(|row| PublicChartPoint { timestamp: row.timestamp, value: row.temperature })
.collect(),
}
}
_ => return Err(AppError::BadRequest(format!("unsupported custom chart series: {key}"))),
};
series.push(item);
}
let (hint, no_data_label) = if lang == "pl" {
(format!("Ostatnie {hours} h"), "Brak danych")
} else {
(format!("Last {hours} h"), "No data")
};
Ok(Json(json!({
"title": share.title,
"hint": hint,
"no_data_label": no_data_label,
"lang": lang,
"hours": hours,
"bucket_seconds": bucket_seconds,
"series": series,
})))
}
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let snapshot = engine::get_control_plan_snapshot(&state).await?;
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
+5 -1
View File
@@ -24,7 +24,11 @@ async fn security_headers(request: Request, next: Next) -> Response {
);
headers.insert(
header::HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("same-origin"),
HeaderValue::from_static(if path.starts_with("/charts/custom/") {
"no-referrer"
} else {
"same-origin"
}),
);
if is_html {
+21 -4
View File
@@ -107,15 +107,32 @@ struct BootstrapResponse {
system: SystemInfoResponse,
}
async fn health(State(state): State<AppState>) -> Json<Value> {
Json(json!({
async fn health(
State(state): State<AppState>,
request: Request,
) -> Result<Json<Value>, AppError> {
if home_assistant::supervisor_token_detected() {
let trusted_supervisor = request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.map(|info| home_assistant::is_supervisor_ingress_peer(info.0.ip()))
.unwrap_or(false);
if !trusted_supervisor {
let expected = state.config.app_token.trim();
if expected.is_empty() || request_token(&request).as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
}
}
}
Ok(Json(json!({
"status": "ok",
"name": "gree-controller",
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(),
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
"time": Utc::now(),
}))
})))
}
async fn bootstrap(State(state): State<AppState>) -> Result<Json<BootstrapResponse>, AppError> {
@@ -162,7 +179,7 @@ fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse
SystemInfoResponse {
version: env!("CARGO_PKG_VERSION"),
uptime_seconds: state.started.elapsed().as_secs(),
auth_required: !state.config.app_token.trim().is_empty(),
auth_required: !state.config.app_token.trim().is_empty() || home_assistant::supervisor_token_detected(),
control_ready: state.initial_device_sync_complete.load(Ordering::Acquire),
database: state.config.database.display().to_string(),
device_count: devices.len(),
+10 -3
View File
@@ -5,11 +5,18 @@ struct WsQuery {
async fn websocket(
State(state): State<AppState>,
Query(query): Query<WsQuery>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> Result<Response, AppError> {
let expected = state.config.app_token.trim();
if !expected.is_empty() && query.token.as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
if !trusted_home_assistant_ingress_parts(&headers, peer.ip()) {
let expected = state.config.app_token.trim();
if home_assistant::supervisor_token_detected() && expected.is_empty() {
return Err(AppError::Unauthorized);
}
if !expected.is_empty() && query.token.as_deref() != Some(expected) {
return Err(AppError::Unauthorized);
}
}
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
}
+24
View File
@@ -109,4 +109,28 @@ impl Db {
let conn = self.lock()?;
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
}
pub fn save_public_chart_share(&self, token_hash: &str, payload: &Value) -> Result<()> {
let conn = self.lock()?;
conn.execute(
queries::INSERT_PUBLIC_CHART_SHARE,
params![
token_hash,
serde_json::to_string(payload)?,
Utc::now().to_rfc3339()
],
)?;
Ok(())
}
pub fn get_public_chart_share(&self, token_hash: &str) -> Result<Option<Value>> {
let conn = self.lock()?;
let payload: Option<String> = conn
.query_row(queries::GET_PUBLIC_CHART_SHARE, [token_hash], |row| row.get(0))
.optional()?;
payload
.map(|value| serde_json::from_str(&value))
.transpose()
.map_err(Into::into)
}
}
+13
View File
@@ -80,6 +80,19 @@ mod tests {
assert!(db.delete_api_token(&access_token.id).unwrap());
assert!(!db.api_token_exists("test-hash").unwrap());
let chart_share = serde_json::json!({
"title": "Room temperatures",
"series": ["device|dev-1|indoor"],
"hours": 24,
"lang": "en"
});
db.save_public_chart_share("chart-hash", &chart_share).unwrap();
assert_eq!(
db.get_public_chart_share("chart-hash").unwrap(),
Some(chart_share)
);
assert!(db.get_public_chart_share("missing-chart").unwrap().is_none());
let now = Utc::now();
db.add_reading(&Reading {
id: 0,
+9 -1
View File
@@ -1,7 +1,11 @@
use crate::models::HomeAssistantSettings;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value;
use std::{env, time::Duration};
use std::{
env,
net::{IpAddr, Ipv4Addr},
time::Duration,
};
use url::Url;
const SUPERVISOR_AUTH_ENV: &str = "GREE_CONTROLLER_HA_AUTH";
@@ -22,6 +26,10 @@ pub fn supervisor_token_detected() -> bool {
.unwrap_or(false)
}
pub fn is_supervisor_ingress_peer(peer: IpAddr) -> bool {
supervisor_token_detected() && peer == IpAddr::V4(Ipv4Addr::new(172, 30, 32, 2))
}
pub fn uses_supervisor_auth(settings: &HomeAssistantSettings) -> bool {
supervisor_detected() && !settings.manual_auth_override
}
+5 -2
View File
@@ -144,7 +144,7 @@ async fn main() -> Result<()> {
address = %config.bind,
database = %config.database.display(),
simulator = config.simulate,
auth = !config.app_token.trim().is_empty(),
auth = !config.app_token.trim().is_empty() || home_assistant::supervisor_token_detected(),
gree_interface = %gree_interface_log,
discovery_broadcast = %runtime_settings.discovery_broadcast,
"GREE Controller started"
@@ -158,7 +158,10 @@ async fn main() -> Result<()> {
let _ = shutdown_tx.send(true);
});
let server = axum::serve(listener, app)
let server = axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
.into_future();
tokio::pin!(server);
+5
View File
@@ -64,6 +64,11 @@ pub const INSERT_API_TOKEN: &str =
pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1";
pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1";
pub const INSERT_PUBLIC_CHART_SHARE: &str =
"INSERT INTO public_chart_shares(token_hash,payload,created_at) VALUES(?1,?2,?3)";
pub const GET_PUBLIC_CHART_SHARE: &str =
"SELECT payload FROM public_chart_shares WHERE token_hash=?1 LIMIT 1";
pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'";
pub const UPSERT_RUNTIME_SETTINGS: &str = r#"
INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2)
+8
View File
@@ -165,6 +165,12 @@ CREATE TABLE IF NOT EXISTS api_tokens (
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS public_chart_shares (
token_hash TEXT PRIMARY KEY,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
@@ -183,5 +189,7 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (9, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
VALUES (10, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
"#;
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#151515" id="themeColorMeta">
<meta name="referrer" content="no-referrer">
<link rel="icon" href="__GREE_BASE_PATH__/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="__GREE_STYLES_ASSET__">
<script src="__GREE_THEME_INIT_ASSET__"></script>
<title>GREE Controller · Custom chart</title>
</head>
<body class="public-custom-chart-page">
<main class="public-custom-chart-shell">
<section class="panel chart-panel history-chart-card public-custom-chart-card" aria-live="polite">
<div class="chart-title-row">
<div>
<h3 id="publicChartTitle">Custom chart</h3>
<p id="publicChartHint">Loading…</p>
</div>
</div>
<div class="chart-wrap public-custom-chart-wrap">
<canvas id="publicCustomChart" width="1200" height="520" tabindex="0" aria-label="Custom chart"></canvas>
<div class="chart-hover-line" hidden></div>
<div class="chart-tooltip" hidden></div>
</div>
<div class="legend" id="publicCustomChartLegend"></div>
<div class="public-custom-chart-error" id="publicChartError" hidden></div>
</section>
</main>
<script src="__GREE_CUSTOM_CHART_ASSET__"></script>
</body>
</html>
+236
View File
@@ -0,0 +1,236 @@
'use strict';
const PUBLIC_CHART_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
const $ = selector => document.querySelector(selector);
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
function basePathFromScript() {
const src = document.currentScript?.src || '';
try {
const path = new URL(src, location.href).pathname;
const slash = path.lastIndexOf('/');
return path.slice(0, slash).replace(/\/$/, '');
} catch (_) { return ''; }
}
const PUBLIC_BASE = basePathFromScript();
const withBase = path => `${PUBLIC_BASE}${path.startsWith('/') ? path : `/${path}`}`;
function cssColor(name, fallback) {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function localeFor(lang) {
return lang === 'pl' ? 'pl-PL' : 'en-GB';
}
function timeLabel(timestamp, hours, locale) {
const options = Number(hours) > 48
? { day: '2-digit', month: '2-digit', hour: '2-digit' }
: { hour: '2-digit', minute: '2-digit' };
return new Date(timestamp).toLocaleString(locale, options);
}
function preciseTime(timestamp, locale) {
return new Intl.DateTimeFormat(locale, {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(new Date(timestamp));
}
function prepareCanvas(canvas, height = 520) {
const wrap = canvas.parentElement;
const width = Math.max(720, Math.floor(wrap?.clientWidth || canvas.getBoundingClientRect().width || 720));
const actualHeight = Math.max(320, Math.floor(wrap?.clientHeight || height));
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(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, actualHeight);
return { ctx, width, height: actualHeight };
}
function renderLegend(series) {
const host = $('#publicCustomChartLegend');
host.innerHTML = series.map((item, index) => {
const color = cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e');
const dashed = item.dashed ? ' is-dashed' : '';
return `<span class="legend-item public-chart-legend-item"><i class="legend-line${dashed}" style="--legend-color:${color}"></i><span>${escapeHtml(item.label)}</span></span>`;
}).join('');
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char]));
}
function drawEmpty(canvas, message) {
const { ctx, width, height } = prepareCanvas(canvas);
ctx.fillStyle = cssColor('--muted', '#888');
ctx.font = '13px system-ui';
ctx.textAlign = 'center';
ctx.fillText(message, width / 2, height / 2);
}
function nearestPoint(points, targetTs) {
if (!points.length) return null;
let lo = 0, hi = points.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (points[mid].ts < targetTs) lo = mid + 1; else hi = mid;
}
const after = points[lo];
const before = lo > 0 ? points[lo - 1] : null;
if (!before) return after;
return Math.abs(before.ts - targetTs) <= Math.abs(after.ts - targetTs) ? before : after;
}
function bindTooltip(canvas, series, geometry, locale) {
const wrap = canvas.parentElement;
const tooltip = wrap.querySelector('.chart-tooltip');
const line = wrap.querySelector('.chart-hover-line');
const { pad, width, height, firstTs, lastTs } = geometry;
const plotWidth = width - pad.left - pad.right;
const timestamps = [...new Set(series.flatMap(item => item.points.map(point => new Date(point.timestamp).getTime())).filter(Number.isFinite))].sort((a, b) => a - b);
const timestampPoints = timestamps.map(ts => ({ ts }));
const seriesPoints = series.map((item, index) => ({
item,
color: cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e'),
points: item.points.map(point => ({ ts: new Date(point.timestamp).getTime(), value: Number(point.value) }))
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
.sort((a, b) => a.ts - b.ts)
}));
const hide = () => { tooltip.hidden = true; line.hidden = true; };
const showAt = clientX => {
if (!timestamps.length) return hide();
const rect = canvas.getBoundingClientRect();
const localX = clamp(clientX - rect.left, pad.left, width - pad.right);
const targetTs = firstTs + ((localX - pad.left) / Math.max(1, plotWidth)) * (lastTs - firstTs);
const snapped = nearestPoint(timestampPoints, targetTs)?.ts;
if (!Number.isFinite(snapped)) return hide();
const values = seriesPoints.map(entry => ({ ...entry, point: nearestPoint(entry.points, snapped) })).filter(entry => entry.point);
if (!values.length) return hide();
const px = pad.left + (snapped - firstTs) / Math.max(1, lastTs - firstTs) * plotWidth;
tooltip.innerHTML = `<strong class="chart-tooltip-time">${escapeHtml(preciseTime(snapped, locale))}</strong><div class="chart-tooltip-values">${values.map(({ item, point, color }) => `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${color}"></i><span>${escapeHtml(item.label)}</span></span><span class="chart-tooltip-value">${Number(point.value).toLocaleString(locale, { minimumFractionDigits: 1, maximumFractionDigits: 2 })} °C</span></div>`).join('')}</div>`;
tooltip.hidden = false;
line.hidden = false;
line.style.left = `${px}px`;
line.style.top = `${pad.top}px`;
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
const minLeft = wrap.scrollLeft + 8;
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
let tooltipLeft = px + 12;
if (tooltipLeft + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) tooltipLeft = px - tooltip.offsetWidth - 12;
tooltip.style.left = `${Math.max(minLeft, Math.min(tooltipLeft, Math.max(minLeft, maxLeft)))}px`;
tooltip.style.top = `${pad.top + 8}px`;
};
canvas.onpointermove = event => { if (event.pointerType !== 'touch') showAt(event.clientX); };
canvas.onpointerleave = event => { if (event.pointerType !== 'touch') hide(); };
}
function drawChart(canvas, payload, locale) {
const series = (payload.series || []).map((item, index) => ({
...item,
color: cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e'),
points: Array.isArray(item.points) ? item.points : []
}));
renderLegend(series);
const allPoints = series.flatMap(item => item.points.map(point => ({ ...point, value: Number(point.value) })))
.filter(point => Number.isFinite(point.value) && Number.isFinite(new Date(point.timestamp).getTime()));
if (!allPoints.length) return drawEmpty(canvas, payload.no_data_label || 'No data');
const { ctx, width, height } = prepareCanvas(canvas);
const text = cssColor('--muted', '#888');
const grid = cssColor('--grid', '#333');
const compact = width < 520;
const pad = compact ? { left: 43, right: 12, top: 16, bottom: 34 } : { left: 54, right: 20, top: 20, bottom: 42 };
const values = allPoints.map(point => point.value);
let min = Math.floor(Math.min(...values) - 1);
let max = Math.ceil(Math.max(...values) + 1);
if (max - min < 2) { min -= 1; max += 1; }
const firstTs = Math.min(...allPoints.map(point => new Date(point.timestamp).getTime()));
const lastTs = Math.max(...allPoints.map(point => new Date(point.timestamp).getTime()));
const span = Math.max(1, lastTs - firstTs);
const x = ts => pad.left + (ts - firstTs) / span * (width - pad.left - pad.right);
const y = value => pad.top + (max - value) / (max - min) * (height - pad.top - pad.bottom);
ctx.font = '10px system-ui';
ctx.fillStyle = text;
ctx.strokeStyle = grid;
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i += 1) {
const value = min + (max - min) * i / 5;
const py = y(value);
ctx.beginPath(); ctx.moveTo(pad.left, py); ctx.lineTo(width - pad.right, py); ctx.stroke();
ctx.textAlign = 'right'; ctx.fillText(`${value.toFixed(1)}°`, pad.left - 8, py + 3);
}
const ticks = width < 420 ? 2 : width < 620 ? 3 : 5;
for (let i = 0; i <= ticks; i += 1) {
const ts = firstTs + (span * i / ticks);
const px = x(ts);
ctx.textAlign = 'center';
ctx.fillText(timeLabel(ts, payload.hours, locale), px, height - 14);
}
series.forEach(item => {
const points = item.points.map(point => ({ ts: new Date(point.timestamp).getTime(), value: Number(point.value) }))
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
.sort((a, b) => a.ts - b.ts);
if (!points.length) return;
ctx.beginPath();
ctx.strokeStyle = item.color;
ctx.lineWidth = 2;
ctx.setLineDash(item.dashed ? [6, 4] : []);
points.forEach((point, index) => {
const px = x(point.ts), py = y(point.value);
if (index === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
});
ctx.stroke();
ctx.setLineDash([]);
});
bindTooltip(canvas, series, { pad, width, height, firstTs, lastTs }, locale);
}
async function loadPublicChart() {
const token = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || '');
const title = $('#publicChartTitle');
const hint = $('#publicChartHint');
const error = $('#publicChartError');
const canvas = $('#publicCustomChart');
if (!token.startsWith('chart_')) {
title.textContent = 'Custom chart';
hint.textContent = '';
error.hidden = false;
error.textContent = 'Invalid chart link.';
drawEmpty(canvas, error.textContent);
return;
}
const response = await fetch(withBase(`/api/public/charts/custom/${encodeURIComponent(token)}`), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) {
let message = `${response.status}`;
try { message = (await response.json()).error || message; } catch (_) { }
throw new Error(message);
}
const payload = await response.json();
const lang = payload.lang === 'pl' ? 'pl' : 'en';
const locale = localeFor(lang);
document.documentElement.lang = payload.lang || lang;
title.textContent = payload.title || (lang === 'pl' ? 'Wykres niestandardowy' : 'Custom chart');
hint.textContent = payload.hint || '';
canvas.setAttribute('aria-label', title.textContent);
drawChart(canvas, payload, locale);
window.addEventListener('resize', () => drawChart(canvas, payload, locale));
}
loadPublicChart().catch(error => {
const host = $('#publicChartError');
host.hidden = false;
host.textContent = `Unable to load chart: ${error.message}`;
$('#publicChartHint').textContent = '';
drawEmpty($('#publicCustomChart'), host.textContent);
});
+28 -3
View File
@@ -311,6 +311,17 @@ function drawCurrentChartIfVisible() {
if (app.currentView === 'history') renderHistoryPage();
}
function publicCustomChartUrl(path) {
if (!APP_BASE.startsWith('/api/hassio_ingress/')) return `${location.origin}${APP_BASE}${path}`;
const bind = String(app.system?.bind || '');
const port = bind.match(/:(\d+)$/)?.[1] || '8787';
const rawHost = location.hostname || 'localhost';
const host = rawHost.includes(':') && !rawHost.startsWith('[') ? `[${rawHost}]` : rawHost;
const configuredBase = String(app.system?.base_path || '').trim();
const directBase = configuredBase === '/' ? '' : configuredBase.replace(/\/$/, '');
return `http://${host}:${port}${directBase}${path}`;
}
async function handleHistoryAction(button) {
const action = button.dataset.historyAction;
if (action === 'add-series') {
@@ -338,9 +349,23 @@ async function handleHistoryAction(button) {
}
if (action === 'copy-chart-link') {
if (!app.customChartSeries.length) return toast(tr('history.noCustomSeries'), true);
const path = currentHistoryPath(); updateBrowserUrl(path, true); const link = `${location.origin}${APP_BASE}${path}`;
try { await navigator.clipboard.writeText(link); } catch (_) { const area = document.createElement('textarea'); area.value = link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
toast(tr('history.linkCopied')); return;
try {
const share = await api('/api/charts/custom/share', {
method: 'POST',
body: {
title: $('#customChartName')?.value.trim() || tr('history.customChart'),
series: [...app.customChartSeries],
hours: Number($('#historyHours')?.value || 24),
lang: app.language === 'pl' ? 'pl' : 'en',
},
});
const link = publicCustomChartUrl(share.path);
try { await navigator.clipboard.writeText(link); } catch (_) { const area = document.createElement('textarea'); area.value = link; document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); }
toast(tr('history.linkCopied'));
} catch (error) {
toast(error.message, true);
}
return;
}
}
+318 -67
View File
@@ -1664,7 +1664,7 @@ button.outside-pill:focus-visible {
font-weight: 650;
}
.outdoor-history-links > div {
.outdoor-history-links>div {
display: flex;
align-items: center;
gap: 7px;
@@ -2937,7 +2937,9 @@ button.outside-pill:focus-visible {
align-items: end;
}
.history-network-controls label { min-width: 0; }
.history-network-controls label {
min-width: 0;
}
.history-jitter-toggle {
min-height: 45px;
@@ -9038,13 +9040,25 @@ body.flow-editor-open {
}
@keyframes cloud-command-pulse {
from { opacity: .72; }
to { opacity: 1; }
from {
opacity: .72;
}
to {
opacity: 1;
}
}
@keyframes cloud-command-dot {
from { transform: scale(.65); opacity: .45; }
to { transform: scale(1); opacity: 1; }
from {
transform: scale(.65);
opacity: .45;
}
to {
transform: scale(1);
opacity: 1;
}
}
@@ -9376,7 +9390,7 @@ body.flow-editor-open {
content: none;
}
}
/* Notification delivery audit in Events. */
.log-row .kind .log-notification-badge {
margin-left: .45rem;
padding: 3px 7px;
@@ -9389,7 +9403,6 @@ body.flow-editor-open {
vertical-align: middle;
}
/* Keep the Flow footer compact before the full mobile layout kicks in. */
@media (min-width:761px) and (max-width:1200px) {
.flow-natural-preview {
display: block;
@@ -9459,14 +9472,13 @@ body.flow-editor-open {
}
}
/* GREE Cloud runtime summary */
.cloud-runtime-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.cloud-runtime-summary > div {
.cloud-runtime-summary>div {
display: grid;
gap: 4px;
min-width: 0;
@@ -9528,7 +9540,6 @@ body.flow-editor-open {
}
}
/* Shared alerts and split/multisplit installation configuration */
.inline-alert {
display: grid;
gap: 4px;
@@ -9559,18 +9570,30 @@ body.flow-editor-open {
color: var(--danger);
}
.inline-alert.error strong { color: var(--danger); }
.inline-alert.error strong {
color: var(--danger);
}
.inline-alert.success {
border-color: color-mix(in srgb, var(--accent) 42%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
}
.inline-alert.info {
border-color: color-mix(in srgb, var(--info) 38%, var(--line));
background: color-mix(in srgb, var(--info) 9%, var(--surface));
}
.inline-alert.wide { grid-column: 1 / -1; }
.device-installation-summary { display: grid; gap: 8px; margin-bottom: 12px; }
.inline-alert.wide {
grid-column: 1 / -1;
}
.device-installation-summary {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.installation-summary-card,
.installation-list-row {
display: grid;
@@ -9582,36 +9605,106 @@ body.flow-editor-open {
border-radius: 12px;
background: var(--surface-muted);
}
.installation-summary-card > div,
.installation-list-row > button:first-child { display: grid; gap: 3px; }
.installation-summary-card>div,
.installation-list-row>button:first-child {
display: grid;
gap: 3px;
}
.installation-summary-card strong,
.installation-list-row strong { color: var(--text); }
.installation-list-row strong {
color: var(--text);
}
.installation-summary-card small,
.installation-list-row small { color: var(--muted); }
.installation-list { display: grid; gap: 8px; }
.installation-list-row > button:first-child { border: 0; background: transparent; padding: 0; text-align: left; }
.installation-list-row > div { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.installation-form { border-top: 1px solid var(--line); padding-top: 14px; margin-top: 14px; }
.installation-device-choices { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin-top: 6px; }
.installation-device-choices .check { margin: 0; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); }
.installation-device-choices .check.disabled { opacity: .55; }
.installation-list-row small {
color: var(--muted);
}
.installation-list {
display: grid;
gap: 8px;
}
.installation-list-row>button:first-child {
border: 0;
background: transparent;
padding: 0;
text-align: left;
}
.installation-list-row>div {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.installation-form {
border-top: 1px solid var(--line);
padding-top: 14px;
margin-top: 14px;
}
.installation-device-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
margin-top: 6px;
}
.installation-device-choices .check {
margin: 0;
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface);
}
.installation-device-choices .check.disabled {
opacity: .55;
}
.installation-energy-metric b {
color: var(--text);
font-size: 18px;
font-variant-numeric: tabular-nums;
letter-spacing: -.01em;
}
.history-context-controls.energy-context-controls {
display: grid;
grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(132px, .8fr));
gap: 10px;
align-items: start;
}
.history-energy-targets { display: grid; gap: 7px; min-width: 0; color: var(--muted); font-size: 12px; font-weight: 650; }
.history-energy-targets {
display: grid;
gap: 7px;
min-width: 0;
color: var(--muted);
font-size: 12px;
font-weight: 650;
}
.history-energy-targets small,
.history-range-control small { display: block; color: var(--muted); font-size: 10px; font-weight: 550; line-height: 1.35; }
.history-energy-picker { position: relative; min-width: 0; }
.history-energy-picker > summary {
.history-range-control small {
display: block;
color: var(--muted);
font-size: 10px;
font-weight: 550;
line-height: 1.35;
}
.history-energy-picker {
position: relative;
min-width: 0;
}
.history-energy-picker>summary {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
@@ -9625,9 +9718,18 @@ body.flow-editor-open {
cursor: pointer;
list-style: none;
}
.history-energy-picker > summary::-webkit-details-marker { display: none; }
.history-energy-picker > summary span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.history-energy-picker > summary b {
.history-energy-picker>summary::-webkit-details-marker {
display: none;
}
.history-energy-picker>summary span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-energy-picker>summary b {
min-width: 34px;
padding: 3px 6px;
border-radius: 999px;
@@ -9636,7 +9738,13 @@ body.flow-editor-open {
font-size: 10px;
text-align: center;
}
.history-energy-picker[open] > summary { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
.history-energy-picker[open]>summary {
border-color: var(--accent);
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
outline-offset: 1px;
}
.history-energy-options {
position: absolute;
z-index: 20;
@@ -9651,6 +9759,7 @@ body.flow-editor-open {
background: var(--surface);
box-shadow: 0 14px 34px rgba(0, 0, 0, .22);
}
.history-energy-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
@@ -9664,28 +9773,67 @@ body.flow-editor-open {
font-size: 11px;
font-weight: 600;
}
.history-energy-option:hover { background: var(--surface-muted); }
.history-energy-option input { width: 17px; min-height: 17px; margin: 0; padding: 0; accent-color: var(--accent); }
.history-energy-option span { overflow-wrap: anywhere; }
.history-range-control.energy-period-control { min-width: 0; }
.history-energy-option:hover {
background: var(--surface-muted);
}
.history-energy-option input {
width: 17px;
min-height: 17px;
margin: 0;
padding: 0;
accent-color: var(--accent);
}
.history-energy-option span {
overflow-wrap: anywhere;
}
.history-range-control.energy-period-control {
min-width: 0;
}
@media (max-width: 760px) {
.installation-summary-card,
.installation-list-row { grid-template-columns: 1fr; }
.installation-list-row > div { justify-content: flex-start; }
.installation-device-choices { grid-template-columns: 1fr; }
.history-context-controls.energy-context-controls { grid-template-columns: 1fr; }
.history-toolbar-panel .chart-toolbar.energy-toolbar .history-refresh-button { margin-top: 0; }
.history-energy-options { width: min(420px, calc(100vw - 40px)); max-height: 46vh; }
.installation-list-row {
grid-template-columns: 1fr;
}
.installation-list-row>div {
justify-content: flex-start;
}
.installation-device-choices {
grid-template-columns: 1fr;
}
.history-context-controls.energy-context-controls {
grid-template-columns: 1fr;
}
.history-toolbar-panel .chart-toolbar.energy-toolbar .history-refresh-button {
margin-top: 0;
}
.history-energy-options {
width: min(420px, calc(100vw - 40px));
max-height: 46vh;
}
}
@media (max-width: 640px) {
.history-network-controls { grid-template-columns: 1fr; }
.history-jitter-toggle { width: 100%; }
.history-network-controls {
grid-template-columns: 1fr;
}
.history-jitter-toggle {
width: 100%;
}
}
/* Unified custom selects -------------------------------------------------- */
.select-native-proxy {
position: absolute !important;
width: 1px !important;
@@ -9874,8 +10022,8 @@ body.flow-editor-open {
padding: 9px 10px;
}
.has-error > .custom-select .custom-select-trigger,
select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-trigger {
.has-error>.custom-select .custom-select-trigger,
select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-trigger {
border-color: color-mix(in srgb, var(--danger) 68%, var(--line));
outline: 2px solid color-mix(in srgb, var(--danger) 13%, transparent);
}
@@ -9886,13 +10034,26 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
}
}
@media (max-width: 900px), (orientation: landscape) and (max-height: 700px) {
.flow-fit-button-icon { display: inline-grid; place-items: center; }
.flow-fit-button-label { display: none; }
@media (max-width: 900px),
(orientation: landscape) and (max-height: 700px) {
.flow-fit-button-icon {
display: inline-grid;
place-items: center;
}
.flow-fit-button-label {
display: none;
}
}
@media (orientation: landscape) and (max-height: 700px) and (min-width: 500px) {
.flow-fit-button-icon { display: none; }
.flow-fit-button-label { display: inline; }
.flow-fit-button-icon {
display: none;
}
.flow-fit-button-label {
display: inline;
}
}
.ha-supervisor-status {
@@ -9905,11 +10066,27 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
background: var(--surface-2);
}
.ha-supervisor-status strong { font-size: .92rem; }
.ha-supervisor-status span { color: var(--muted); font-size: .82rem; line-height: 1.45; }
.ha-supervisor-status.success { border-left-color: var(--teal); }
.ha-supervisor-status.warning { border-left-color: var(--accent); }
.ha-supervisor-status.error { border-left-color: var(--danger); }
.ha-supervisor-status strong {
font-size: .92rem;
}
.ha-supervisor-status span {
color: var(--muted);
font-size: .82rem;
line-height: 1.45;
}
.ha-supervisor-status.success {
border-left-color: var(--teal);
}
.ha-supervisor-status.warning {
border-left-color: var(--accent);
}
.ha-supervisor-status.error {
border-left-color: var(--danger);
}
.ha-entity-picker {
display: grid;
@@ -9917,8 +10094,13 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
min-width: 0;
}
.ha-entity-picker > label { margin: 0; }
.ha-entity-picker > .field-note { margin: 0; }
.ha-entity-picker>label {
margin: 0;
}
.ha-entity-picker>.field-note {
margin: 0;
}
.ha-entity-results {
display: grid;
@@ -9951,7 +10133,7 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
background: var(--surface-2);
}
.ha-entity-option > span {
.ha-entity-option>span {
display: grid;
gap: 2px;
min-width: 0;
@@ -9971,10 +10153,79 @@ select.select-native-proxy[aria-invalid="true"] + .custom-select .custom-select-
font-size: .76rem;
}
.ha-entity-option small { flex: 0 0 auto; }
.ha-entity-empty { padding: 9px 10px; }
.ha-entity-option small {
flex: 0 0 auto;
}
/* Shared Flow HA entity picker: keep search and selected entity visually distinct. */
.ha-entity-search-field input[type="search"] { width: 100%; }
.ha-entity-selected-field { margin-top: 2px; }
.ha-entity-selected-field input { font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); }
.ha-entity-empty {
padding: 9px 10px;
}
.ha-entity-search-field input[type="search"] {
width: 100%;
}
.ha-entity-selected-field {
margin-top: 2px;
}
.ha-entity-selected-field input {
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
}
.public-custom-chart-page {
min-height: 100vh;
overflow: hidden;
}
.public-custom-chart-shell {
width: 100%;
min-height: 100vh;
padding: 16px;
}
.public-custom-chart-card {
display: flex;
min-height: calc(100vh - 32px);
margin: 0;
flex-direction: column;
}
.public-custom-chart-wrap {
flex: 1 1 auto;
min-height: 360px;
}
#publicCustomChart {
width: 100%;
min-width: 720px;
height: 100%;
min-height: 420px;
}
.public-chart-legend-item {
cursor: default;
}
.public-chart-legend-item:hover {
border-color: transparent;
background: transparent;
color: var(--muted);
}
.public-custom-chart-error {
margin-top: 12px;
color: var(--danger);
font-size: 12px;
}
@media (max-width: 700px) {
.public-custom-chart-shell {
padding: 8px;
}
.public-custom-chart-card {
min-height: calc(100vh - 16px);
padding: 14px;
}
}