diff --git a/.env.example b/.env.example index 066e34e..4f3e836 100644 --- a/.env.example +++ b/.env.example @@ -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=... diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 2ad8657..7c43c36 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index cb0786e..224046a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SOURCE_MANIFEST.sha256 b/SOURCE_MANIFEST.sha256 index dd04ee7..e532c8c 100644 --- a/SOURCE_MANIFEST.sha256 +++ b/SOURCE_MANIFEST.sha256 @@ -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 diff --git a/docs/API.md b/docs/API.md index 7b7ff67..7f4569a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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_`. 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**. diff --git a/docs/openapi.json b/docs/openapi.json index d4fcbb4..d5c12dd 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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": { diff --git a/ha-addon/repository/gree-controller/CHANGELOG.md b/ha-addon/repository/gree-controller/CHANGELOG.md index 825d238..b16533f 100644 --- a/ha-addon/repository/gree-controller/CHANGELOG.md +++ b/ha-addon/repository/gree-controller/CHANGELOG.md @@ -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. diff --git a/ha-addon/repository/gree-controller/DOCS.md b/ha-addon/repository/gree-controller/DOCS.md index e0ad60d..352cfbb 100644 --- a/ha-addon/repository/gree-controller/DOCS.md +++ b/ha-addon/repository/gree-controller/DOCS.md @@ -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/`, `/api/public/charts/custom/`). 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/`, `/api/public/charts/custom/`). 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. diff --git a/ha-addon/repository/gree-controller/translations/en.yaml b/ha-addon/repository/gree-controller/translations/en.yaml index 8574c36..37c0105 100644 --- a/ha-addon/repository/gree-controller/translations/en.yaml +++ b/ha-addon/repository/gree-controller/translations/en.yaml @@ -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. diff --git a/ha-addon/repository/gree-controller/translations/pl.yaml b/ha-addon/repository/gree-controller/translations/pl.yaml index 64e7fd2..5e972cd 100644 --- a/ha-addon/repository/gree-controller/translations/pl.yaml +++ b/ha-addon/repository/gree-controller/translations/pl.yaml @@ -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. diff --git a/src/api.rs b/src/api.rs index 96c0415..ebf32e3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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)) diff --git a/src/api/assets.rs b/src/api/assets.rs index 441d23f..89abb15 100644 --- a/src/api/assets.rs +++ b/src/api/assets.rs @@ -29,6 +29,34 @@ async fn not_found(State(state): State, headers: HeaderMap) -> Respons response } +async fn custom_chart_page(State(state): State, 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, headers: HeaderMap) -> Response { let base = request_base_path(&state, &headers); let body = INDEX_HTML @@ -88,6 +116,13 @@ fn forwarded_prefix(headers: &HeaderMap) -> Option { } 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, diff --git a/src/api/auth.rs b/src/api/auth.rs index 2550f45..0a8f83d 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -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::>() + .map(|info| trusted_home_assistant_ingress_parts(request.headers(), info.0.ip())) + .unwrap_or(false) +} + async fn auth( State(state): State, request: Request, next: Next, ) -> Result { + 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); } diff --git a/src/api/history.rs b/src/api/history.rs index c814f55..3aebe1f 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -495,6 +495,274 @@ async fn history( } } +#[derive(Debug, Deserialize)] +struct CreatePublicCustomChartRequest { + title: Option, + series: Vec, + hours: Option, + lang: Option, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +struct PublicCustomChartShare { + title: String, + series: Vec, + hours: i64, + lang: String, +} + +#[derive(Debug, serde::Serialize)] +struct PublicChartPoint { + timestamp: chrono::DateTime, + value: f64, +} + +#[derive(Debug, serde::Serialize)] +struct PublicChartSeries { + key: String, + label: String, + dashed: bool, + points: Vec, +} + +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, + Json(input): Json, +) -> Result, 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::(); + 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, field: &str) -> Vec { + 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, field: &str) -> Vec { + 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, + Path(token): Path, +) -> Result, 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) -> Result, AppError> { let snapshot = engine::get_control_plan_snapshot(&state).await?; Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?)) diff --git a/src/api/middleware.rs b/src/api/middleware.rs index 6636de3..bc3ee1c 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -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 { diff --git a/src/api/system.rs b/src/api/system.rs index 652da8d..72be144 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -107,15 +107,32 @@ struct BootstrapResponse { system: SystemInfoResponse, } -async fn health(State(state): State) -> Json { - Json(json!({ +async fn health( + State(state): State, + request: Request, +) -> Result, AppError> { + if home_assistant::supervisor_token_detected() { + let trusted_supervisor = request + .extensions() + .get::>() + .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) -> Result, 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(), diff --git a/src/api/websocket.rs b/src/api/websocket.rs index fc71424..d5f1cba 100644 --- a/src/api/websocket.rs +++ b/src/api/websocket.rs @@ -5,11 +5,18 @@ struct WsQuery { async fn websocket( State(state): State, Query(query): Query, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, ws: WebSocketUpgrade, ) -> Result { - 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))) } diff --git a/src/db/events_tokens.rs b/src/db/events_tokens.rs index 72251ce..aaf4213 100644 --- a/src/db/events_tokens.rs +++ b/src/db/events_tokens.rs @@ -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> { + let conn = self.lock()?; + let payload: Option = 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) + } } diff --git a/src/db/tests.rs b/src/db/tests.rs index 9a69379..26144c0 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -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, diff --git a/src/home_assistant.rs b/src/home_assistant.rs index a784448..f28e514 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -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 } diff --git a/src/main.rs b/src/main.rs index 8acd70d..2ce3ac7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::(), + ) .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) .into_future(); tokio::pin!(server); diff --git a/src/queries/maintenance.rs b/src/queries/maintenance.rs index 4930eb3..c9790df 100644 --- a/src/queries/maintenance.rs +++ b/src/queries/maintenance.rs @@ -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) diff --git a/src/queries/schema.rs b/src/queries/schema.rs index 581ee6b..65c7da1 100644 --- a/src/queries/schema.rs +++ b/src/queries/schema.rs @@ -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')); "#; diff --git a/web/custom-chart.html b/web/custom-chart.html new file mode 100644 index 0000000..9a4b093 --- /dev/null +++ b/web/custom-chart.html @@ -0,0 +1,33 @@ + + + + + + + + + + + GREE Controller · Custom chart + + +
+
+
+
+

Custom chart

+

Loading…

+
+
+
+ + + +
+
+ +
+
+ + + diff --git a/web/custom-chart.js b/web/custom-chart.js new file mode 100644 index 0000000..46c5bf0 --- /dev/null +++ b/web/custom-chart.js @@ -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 `${escapeHtml(item.label)}`; + }).join(''); +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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 = `${escapeHtml(preciseTime(snapped, locale))}
${values.map(({ item, point, color }) => `
${escapeHtml(item.label)}${Number(point.value).toLocaleString(locale, { minimumFractionDigits: 1, maximumFractionDigits: 2 })} °C
`).join('')}
`; + 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); +}); diff --git a/web/js/history.js b/web/js/history.js index 0e4ac88..03a6e04 100644 --- a/web/js/history.js +++ b/web/js/history.js @@ -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; } } diff --git a/web/styles.css b/web/styles.css index 257eec4..2d3c23f 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; + } +} \ No newline at end of file