diff --git a/Cargo.lock b/Cargo.lock index 98ce744..a5f490c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,7 +700,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.13.7" +version = "0.13.8" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index a435c90..d7302ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.13.7" +version = "0.13.8" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 4e26824..353bf9a 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,13 +1,13 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -d4330d1b80dd2bf948a5157c97581c2ca10dc69b7d6740fd55c48a699c2e3bc6 ./Cargo.lock -71d0ce719983e6d41bd89b592a6eaf66658479c0c30424b88d1133d80936d4aa ./Cargo.toml +ff8ef8ed21e7d3617f9939cd9e809bff06744ffc99202e037b2f31ade4157867 ./Cargo.lock +fb8bbd5479705582e3aad71568ada6892bc266a56be6ec3d69647a4b8dd30b69 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -a7acf66c0bbae0c6ca3faa2993b4b8ba80fec935902d0102f34693ffc96b7a19 ./README.md -d56cfff4179a6511aa764a91c6ddebe21fcd35f36b7298e62d012e11f62bfeb9 ./build.rs -6d42d846df9e6115acedd9ac0ba84d05689046dc3f71fb2311c074638488d8f6 ./docs/API.md +cd7b1af125c0d65b52042a844bfe9e9575d1c5bde31ebddfc2534231a7599fbb ./README.md +bb549919b96b0413152dd905aec8640bb53301f07ec7d0b1be48ccbd37936fef ./build.rs +eff2912449a4b78e0b9eb4ff02fa6752e31abcb71fa097a46f1d7596a4500303 ./docs/API.md 2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md -6da82ee5007330b210a1645aed7df7951f8c858e04009631e89de51f32815ecf ./docs/openapi.json +8f3ca55ab14f855644b3c85074400bf4a4a6b37659f4aa5dd686757a0d2bcd56 ./docs/openapi.json b11e581dd916676e24402112fe6896bc681abf3c94cafc85c4d3ded3f3a272a3 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./home-assistant/custom_components/gree_controller/api.py @@ -18,7 +18,7 @@ b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assista 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -542d58cc7f47b55c786619c831f7bcdecaa3699940f29d9a7f26f01aaa0f0547 ./home-assistant/custom_components/gree_controller/manifest.json +9b31873cdc44b589a4230346e6cd9ee25325b431ccf06b39f07393793410bf65 ./home-assistant/custom_components/gree_controller/manifest.json c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py @@ -69,7 +69,7 @@ fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/wind 804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json 01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md -b1e6a729a28eec03b38f013377d26b95669b58c035802c96ea9201cb633d87b2 ./scripts/api_dev_test.py +615b8c7c1c085f3dba2ddc10184b2223b2106c94f7a69ccc0592bf05b2323f27 ./scripts/api_dev_test.py 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh @@ -82,8 +82,8 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 472db31959be29ba99247f7e9cc07a44bc090c538cd654ad8d16e094726037a3 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -6b6cab48685c56893fbe31174460254347d73a354598d0bb1f22b195df91da3e ./src/api.rs -5027797b9cefb9f0aa04c792e2ac9a38745c6bf1ee822f50eb70574fa6f997e2 ./src/api/assets.rs +0da4e4fcb8e77109fd56b301472d08acbab5ad9ecfe861296153a6a5eef723b3 ./src/api.rs +549941b91d9f9bb829f9f64345866a1a76eebcb323d15806d9d11545566dfe6b ./src/api/assets.rs 05c255919f7b3d81188fc5f1410be6a0037471dfc44e93bffb009f29a1e6a3fc ./src/api/auth.rs 9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs 368cd843dcac970f1aa6800ba132a4bbc3429e8379ed7abf9878dcb0c633af40 ./src/api/configuration.rs @@ -176,24 +176,25 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service 544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -9e98df7a759bd76bc7682b23c3c4b7e29f7bde972f03ae316ba7f8159301e2c6 ./web/index.html +2808c2152a3dc56d80c158980f2a00d73e078b16731bdff240a62431ca95dea9 ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md f272912f6e5d8b34786827d78ba592513e3ca1e85e07e4c073089c5bbb75f282 ./web/js/bootstrap.js badde8a5214a0322b2c45803c02a1cb723d53b25e57c687369cd804f509d9744 ./web/js/charts.js -dd596b3362643b96be8537b9415e8c1be90d7cf458124b61121016a6742951c7 ./web/js/core.js +ff427103d12cb99977cf1b32e6978506b6ee5179a10568b13673292c13ae3b65 ./web/js/core.js d880ffdac6aa07cd10d79fbacf12f87eb4c69b48a80564bbfdd0248abe8884d5 ./web/js/dashboard.js 6c0d310e39a1e1f618a31fbdf5560a850023bf2219d897c69d636d0179dff004 ./web/js/entities.js 0a23ac65a4a0bd113a9f78fe1d25621d6a178e10aaca53e5df8df87587998cb4 ./web/js/events.js 2e822a00b5a6941b6a251eaf2c54ca95a53399b5d1056f3d8240e11f82d6065f ./web/js/flows.js cf1ebf3b9d666f309a1add070b4da86be86d990b02c8d6474f6d121c6d332746 ./web/js/forms.js dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js -7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js +e934dbe7810da4565a4474338955b044448f3a268160e55bf98afefa4b2d97de ./web/js/main.js 733fcc595ed9c03c9639acc6caba46f0588f29e0b95bf72891e34443e7aa9db8 ./web/js/navigation.js e0a6b5d2900f5524dba395b97288f8d75976e31b27e26810d42febbfd723085c ./web/js/realtime.js c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js 25f5c9a0aa8622f419b05f64712c9eca4896f0e79818616904b83051fd77b07b ./web/js/settings-ui.js 4aadfdf107593d32ab73514688f225bf559fd5ce6bbf9d9459a8098daf1fbe3d ./web/js/settings.js +3815fea7ae1694568c2f249bc8460e0563ced6bde89214f74f21b41fe8cad9a6 ./web/lang-init.js 6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest -0b9774bb388158e23f8c90297d70659a015e0db58d25eadcae316a49e3c9fcb7 ./web/styles.css -ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js +8ea4f5cf1ebdf3894e17457020bde6d5fae324eadcc0ff35a6ac4efe5d5b2b6a ./web/styles.css +fefdf12cbbb0a5d8a2cb89275cae28455aa42aaf179542305cc8f5e0f4825898 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index 2ebc013..661fd63 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Current release: 0.13.7** +**Current release: 0.13.8** -Release 0.13.7 consolidates the Web UI startup snapshot: `/api/bootstrap` now includes the same redacted settings views exposed by the split settings endpoints, eliminating redundant startup/resync requests while keeping the functional settings API unchanged. Bootstrap/system response construction and WebSocket state application are also deduplicated. +Release 0.13.8 removes the visible English-to-selected-language flash during Web UI startup. A dedicated `lang-init.js` applies the preferred language before first paint, the application is revealed only after localization initialization, and content-versioned language packs can use the browser cache safely. Release 0.13.6 adds a dedicated notification toggle for GREE vs Home Assistant room-temperature discrepancies. Disabling this alert suppresses `zone.sensor_discrepancy` notifications only; sensor fallback and thermostat safety behavior are unchanged. diff --git a/build.rs b/build.rs index e8a4490..2f6d560 100644 --- a/build.rs +++ b/build.rs @@ -78,6 +78,7 @@ fn main() { println!("cargo:rerun-if-changed={}", preset_dir.display()); let theme_init_path = web_dir.join("theme-init.js"); + let lang_init_path = web_dir.join("lang-init.js"); let styles_path = web_dir.join("styles.css"); let index_path = web_dir.join("index.html"); let not_found_path = web_dir.join("404.html"); @@ -86,6 +87,7 @@ fn main() { let favicon_path = web_dir.join("favicon.svg"); for path in [ &theme_init_path, + &lang_init_path, &styles_path, &index_path, ¬_found_path, @@ -100,17 +102,22 @@ fn main() { let app_js_hash = content_hash(app_js.as_bytes()); let theme_init_bytes = fs::read(&theme_init_path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", theme_init_path.display())); + let lang_init_bytes = fs::read(&lang_init_path) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_init_path.display())); let styles_bytes = fs::read(&styles_path) .unwrap_or_else(|error| panic!("cannot read {}: {error}", styles_path.display())); let theme_init_hash = content_hash(&theme_init_bytes); + let lang_init_hash = content_hash(&lang_init_bytes); let styles_hash = content_hash(&styles_bytes); let app_js_asset_path = format!("/app-{}.js", &app_js_hash[..12]); let theme_init_asset_path = format!("/theme-init-{}.js", &theme_init_hash[..12]); + let lang_init_asset_path = format!("/lang-init-{}.js", &lang_init_hash[..12]); let styles_asset_path = format!("/styles-{}.css", &styles_hash[..12]); let mut build_hash = fnv1a_update(0xcbf29ce484222325u64, app_js.as_bytes()); for path in [ &theme_init_path, + &lang_init_path, &styles_path, &index_path, ¬_found_path, @@ -189,12 +196,13 @@ fn main() { has_english = true; } + let language_hash = content_hash(source.as_bytes()); manifest_languages.push(json!({ "code": code, "name": name, "native_name": native_name, "locale": locale, - "path": format!("lang/{code}.json") + "path": format!("lang/{code}.json?v={}", &language_hash[..12]) })); assets.push((code.to_owned(), path)); } @@ -298,6 +306,10 @@ fn main() { "pub const THEME_INIT_ASSET_PATH: &str = {:?};\n", theme_init_asset_path )); + generated.push_str(&format!( + "pub const LANG_INIT_ASSET_PATH: &str = {:?};\n", + lang_init_asset_path + )); generated.push_str(&format!( "pub const STYLES_CSS_ASSET_PATH: &str = {:?};\n", styles_asset_path diff --git a/docs/API.md b/docs/API.md index 2472718..4f2705b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.13.7**. +HTTP and WebSocket API for GREE Controller **0.13.8**. [← Main documentation](../README.md) @@ -48,6 +48,7 @@ GET / GET /index.html GET /app.js GET /theme-init.js +GET /lang-init.js GET /styles.css GET /manifest.webmanifest GET /sw.js @@ -231,7 +232,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.13.7", + "version": "0.13.8", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -294,7 +295,7 @@ Returns the initial Web UI snapshot: "control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []}, "control_plan_revision": 42, "system": { - "version": "0.13.7", + "version": "0.13.8", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/docs/openapi.json b/docs/openapi.json index 7698984..1ef06d6 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,9 +2,9 @@ "openapi": "3.1.0", "info": { "title": "GREE Controller API", - "version": "0.13.7", + "version": "0.13.8", "summary": "Local HTTP/WebSocket API for GREE HVAC control", - "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.\n\n### 0.13.0\n`control-plan` keeps its existing HTTP response shape but is materialized in memory and pushed to WebSocket clients as `control_plan.updated`; HTTP remains the fallback/resync path. A restricted Home Assistant snapshot endpoint combines devices, groups and the control plan.\n\n### 0.13.1\nRuntime WebSocket churn is reduced without changing contracts: no-op zone regulator heartbeats are deduplicated and device heartbeat polls only invalidate the materialized control plan when plan-relevant device state changes.\n\n### 0.13.2\nCompletes live WebSocket synchronization for entity creation, schedules, automations, configuration imports and runtime device/automation state while preserving the existing HTTP API contracts and control-plan fallback behavior.\n\n### 0.13.3\nLogical thermostat targets are normalized to 0.1 C consistently, including Temporary Quick Thermostat and thermostat-managed automation/Flow actions. The bundled Web UI accepts comma or dot decimal input for the temporary thermostat target; physical GREE setpoint rounding is unchanged.\n\n### 0.13.4\nDisabled-zone thermostat controls remain visually active in the bundled Web UI because explicit temporary/manual thermostat and direct manual control remain available while zone automation is disabled. HTTP and WebSocket contracts are unchanged.\n\n### 0.13.5\nThe zone settings dialog now explains directly beside the enabled/disabled option that disabling a zone stops automation but does not block manual thermostat, temporary thermostat or direct manual control. HTTP and WebSocket contracts remain unchanged.\n\n### 0.13.6\nNotification settings add a dedicated sensor_discrepancy alert type for GREE vs Home Assistant room-temperature divergence. Disabling it suppresses only that notification; the GREE safety fallback remains active.\n\n### 0.13.7\nThe bootstrap snapshot now embeds all eight redacted runtime settings views. The bundled Web UI consumes those settings directly for HTTP startup and WebSocket resynchronization, removing the previous settings-request fan-out while preserving the split settings endpoints for direct reads and writes.", + "description": "Self-hosted API used by the GREE Controller Web UI and Home Assistant integration.\n\n### Authentication\nNormal administrator endpoints accept `Authorization: Bearer ` or `x-api-token: ` when `GREE_CONTROLLER_APP_TOKEN` is configured. If the app token is empty, those endpoints intentionally run in trusted-LAN mode without authentication.\n\nThe restricted `/api/integrations/home-assistant/*` control surface always requires either a generated access token or the administrator app token. Generated tokens do **not** grant normal administrator API access.\n\nIn Swagger UI use **Authorize** and enter the token in either the bearer field or `x-api-token` field.\n\n### Control ownership\nThermostat zones, groups, house rules, direct device control, schedules and visual Flows share an ownership/arbitration model. Prefer zone/group/house endpoints for thermostat intent; direct device commands are technical/manual control and can deliberately take ownership.\n\n### 0.12.0 breaking API\nRuntime settings are split into functional `/api/settings/*` resources. The former `/api/settings`, `/api/debug`, `/api/events/retention`, `/api/settings/export` and `/api/settings/import` endpoints were removed without compatibility aliases.\n\n### 0.13.0\n`control-plan` keeps its existing HTTP response shape but is materialized in memory and pushed to WebSocket clients as `control_plan.updated`; HTTP remains the fallback/resync path. A restricted Home Assistant snapshot endpoint combines devices, groups and the control plan.\n\n### 0.13.1\nRuntime WebSocket churn is reduced without changing contracts: no-op zone regulator heartbeats are deduplicated and device heartbeat polls only invalidate the materialized control plan when plan-relevant device state changes.\n\n### 0.13.2\nCompletes live WebSocket synchronization for entity creation, schedules, automations, configuration imports and runtime device/automation state while preserving the existing HTTP API contracts and control-plan fallback behavior.\n\n### 0.13.3\nLogical thermostat targets are normalized to 0.1 C consistently, including Temporary Quick Thermostat and thermostat-managed automation/Flow actions. The bundled Web UI accepts comma or dot decimal input for the temporary thermostat target; physical GREE setpoint rounding is unchanged.\n\n### 0.13.4\nDisabled-zone thermostat controls remain visually active in the bundled Web UI because explicit temporary/manual thermostat and direct manual control remain available while zone automation is disabled. HTTP and WebSocket contracts are unchanged.\n\n### 0.13.5\nThe zone settings dialog now explains directly beside the enabled/disabled option that disabling a zone stops automation but does not block manual thermostat, temporary thermostat or direct manual control. HTTP and WebSocket contracts remain unchanged.\n\n### 0.13.6\nNotification settings add a dedicated sensor_discrepancy alert type for GREE vs Home Assistant room-temperature divergence. Disabling it suppresses only that notification; the GREE safety fallback remains active.\n\n### 0.13.7\nThe bootstrap snapshot now embeds all eight redacted runtime settings views. The bundled Web UI consumes those settings directly for HTTP startup and WebSocket resynchronization, removing the previous settings-request fan-out while preserving the split settings endpoints for direct reads and writes.\n\n### 0.13.8\nThe bundled Web UI suppresses the initial English-to-selected-language flash with a dedicated pre-paint language initializer and reveals the application only after localization is initialized. Language-pack URLs are content-versioned so immutable browser caching stays safe across updates.", "license": { "name": "MIT" } @@ -4891,7 +4891,7 @@ }, "version": { "type": "string", - "example": "0.13.7" + "example": "0.13.8" }, "uptime_seconds": { "type": "integer", diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index d807461..e79707f 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.13.7", + "version": "0.13.8", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/scripts/api_dev_test.py b/scripts/api_dev_test.py index 4cfce31..c36eb80 100644 --- a/scripts/api_dev_test.py +++ b/scripts/api_dev_test.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Developer smoke/integration tests for GREE Controller API 0.13.7. +"""Developer smoke/integration tests for GREE Controller API 0.13.8. Default mode is read-only and safe to run against a real controller. Use --settings-write to additionally round-trip all split settings resources and @@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787") DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "") -DEFAULT_EXPECTED_VERSION = "0.13.7" +DEFAULT_EXPECTED_VERSION = "0.13.8" SETTINGS_PATHS: dict[str, set[str]] = { "/api/settings/application": {"simulator_enabled"}, @@ -553,7 +553,7 @@ def run_suite(args: argparse.Namespace) -> int: print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n") runner.run("public health", lambda: test_health(client, args.expected_version)) - runner.run("OpenAPI 0.13.7 contract", lambda: test_openapi(client, args.expected_version)) + runner.run("OpenAPI 0.13.8 contract", lambda: test_openapi(client, args.expected_version)) runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client)) runner.run("protected API requires auth", lambda: test_protected_auth(client)) @@ -622,7 +622,7 @@ def run_suite(args: argparse.Namespace) -> int: def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser( - description="Developer smoke/integration tests for GREE Controller API 0.13.7.", + description="Developer smoke/integration tests for GREE Controller API 0.13.8.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: python3 scripts/api_dev_test.py diff --git a/src/api.rs b/src/api.rs index 4b42c83..47f308c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -50,6 +50,7 @@ const INDEX_HTML: &str = include_str!("../web/index.html"); 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"); +const LANG_INIT_JS: &str = include_str!("../web/lang-init.js"); const STYLES_CSS: &str = include_str!("../web/styles.css"); const MANIFEST: &str = include_str!("../web/manifest.webmanifest"); const SERVICE_WORKER: &str = include_str!("../web/sw.js"); @@ -257,6 +258,7 @@ pub fn router(state: AppState) -> Router { .route("/index.html", get(index)) .route(APP_JS_ASSET_PATH, get(app_js)) .route(THEME_INIT_ASSET_PATH, get(theme_init_js)) + .route(LANG_INIT_ASSET_PATH, get(lang_init_js)) .route(STYLES_CSS_ASSET_PATH, get(styles_css)) .route("/manifest.webmanifest", get(manifest)) .route("/sw.js", get(service_worker)) diff --git a/src/api/assets.rs b/src/api/assets.rs index c699ac4..3e9d00a 100644 --- a/src/api/assets.rs +++ b/src/api/assets.rs @@ -38,6 +38,10 @@ async fn index(State(state): State, headers: HeaderMap) -> Response { "__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"), ) + .replace( + "__GREE_LANG_INIT_ASSET__", + &format!("{base}{LANG_INIT_ASSET_PATH}"), + ) .replace( "__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"), @@ -95,6 +99,13 @@ async fn theme_init_js() -> Response { "public, max-age=31536000, immutable", ) } +async fn lang_init_js() -> Response { + static_response( + LANG_INIT_JS, + "application/javascript; charset=utf-8", + "public, max-age=31536000, immutable", + ) +} async fn styles_css() -> Response { static_response( STYLES_CSS, @@ -114,6 +125,7 @@ async fn service_worker() -> Response { .replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID) .replace("__GREE_APP_ASSET__", APP_JS_ASSET_PATH) .replace("__GREE_THEME_INIT_ASSET__", THEME_INIT_ASSET_PATH) + .replace("__GREE_LANG_INIT_ASSET__", LANG_INIT_ASSET_PATH) .replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH); owned_response(body, "application/javascript; charset=utf-8", "no-cache") } @@ -133,7 +145,7 @@ async fn language_file(Path(file): Path) -> Response { .iter() .find(|(language, _)| *language == code) { - return static_response(*body, "application/json; charset=utf-8", "no-cache"); + return static_response(*body, "application/json; charset=utf-8", "public, max-age=31536000, immutable"); } let mut response = Response::new(Body::from("Language not found")); *response.status_mut() = StatusCode::NOT_FOUND; diff --git a/web/index.html b/web/index.html index 7762466..c990d2d 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,7 @@ + GREE Controller diff --git a/web/js/core.js b/web/js/core.js index 3de703d..a374eaf 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -265,19 +265,14 @@ function renderLanguageOptions() { async function loadLanguages() { try { - const languageNonce = Date.now().toString(36); - const languageUrl = path => { - const separator = String(path).includes('?') ? '&' : '?'; - return `${withBase(path)}${separator}v=${languageNonce}`; - }; - const response = await fetch(languageUrl('/lang/index.json'), { cache: 'no-store' }); + const response = await fetch(withBase('/lang/index.json')); if (!response.ok) throw new Error(`Language index HTTP ${response.status}`); const manifest = await response.json(); const languages = Array.isArray(manifest.languages) ? manifest.languages : []; if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing'); const loaded = await Promise.all(languages.map(async item => { - const packResponse = await fetch(languageUrl(item.path || `/lang/${encodeURIComponent(item.code)}.json`), { cache: 'no-store' }); + const packResponse = await fetch(withBase(item.path || `/lang/${encodeURIComponent(item.code)}.json`)); if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`); const pack = await packResponse.json(); return { item, pack }; diff --git a/web/js/main.js b/web/js/main.js index 88c953a..06fdafd 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -4,10 +4,14 @@ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register(withBase('/sw.js')).catch(() => { })); async function startApplication() { - observeTopbarHeight(); - applyTheme(); - await loadLanguages(); - applyTranslations(); + try { + observeTopbarHeight(); + applyTheme(); + await loadLanguages(); + applyTranslations(); + } finally { + document.documentElement.classList.remove('i18n-loading'); + } setupFormUx(); updateZoneSensorFields(); updateSchedulePresetField(); diff --git a/web/lang-init.js b/web/lang-init.js new file mode 100644 index 0000000..416a933 --- /dev/null +++ b/web/lang-init.js @@ -0,0 +1,7 @@ +'use strict'; +(() => { + const row = document.cookie.split('; ').find(item => item.startsWith('gree_controller_language=')); + const language = row ? decodeURIComponent(row.split('=').slice(1).join('=')) : 'en'; + document.documentElement.lang = language || 'en'; + document.documentElement.classList.add('i18n-loading'); +})(); diff --git a/web/styles.css b/web/styles.css index 64607e8..b9cd152 100644 --- a/web/styles.css +++ b/web/styles.css @@ -95,6 +95,10 @@ body { color: var(--text); } +html.i18n-loading body { + visibility: hidden; +} + button, input, select { diff --git a/web/sw.js b/web/sw.js index 34f3214..9513b9e 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,7 +1,7 @@ const CACHE = 'gree-controller-__GREE_ASSET_CACHE__'; const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/'; -const ASSETS = [path('__GREE_STYLES_ASSET__'), path('__GREE_APP_ASSET__'), path('__GREE_THEME_INIT_ASSET__'), path('/favicon.svg'), path('/manifest.webmanifest')]; +const ASSETS = [path('__GREE_STYLES_ASSET__'), path('__GREE_APP_ASSET__'), path('__GREE_THEME_INIT_ASSET__'), path('__GREE_LANG_INIT_ASSET__'), path('/favicon.svg'), path('/manifest.webmanifest')]; self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting()))); self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()))); self.addEventListener('fetch', event => {