diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 7a94f41..21a03eb 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,11 +1,12 @@ -# GREE Controller v0.5.0 - build and validation report +# GREE Controller v0.5.2 - build and validation report ## Scope -Version 0.5.0 focuses on dashboard thermostat control, metric retention/archiving, quieter GREE commands, diagnostics, configuration backup and a machine-readable automation plan shared by the Web UI and Home Assistant. +Version 0.5.2 includes the 0.5.0 dashboard/metrics/diagnostics release plus thermostat noise-control fixes. ## Implemented changes +- Added a dedicated Web UI simulator page that visualizes thermostat/automation decisions as graphical blocks with hours, targets, demand, standby behavior, rule actions and upcoming events. - Quick Thermostat cards on the dashboard can enable/disable a zone directly; the edit dialog is no longer required for this action. - SQLite history housekeeping is tiered to chart resolution: full recent data, 10-minute buckets after 24 hours and 30-minute buckets after 7 days. - Optional long-term InfluxDB archive supports both InfluxDB 1.x (database/basic auth) and 2.x (org/bucket/token), from persisted settings or environment variables. @@ -13,6 +14,7 @@ Version 0.5.0 focuses on dashboard thermostat control, metric retention/archivin - Maintenance backfills compacted legacy SQLite samples older than the configured Influx handoff age. A local row is deleted only after the Influx batch succeeds; archive failures keep the SQLite copy. - History requests crossing the handoff age merge old InfluxDB data with recent SQLite data and fall back to SQLite when the archive query fails. - Device commands are reduced to fields that actually changed. Optional GREE buzzer suppression uses `Buzzer_ON_OFF`/`BuzzerCtrl`; incompatible devices automatically fall back to normal frames and are remembered for the running process. +- Smart Fan now uses Low speed (`WdSpd=1`) when a zone is satisfied. The thermostat sends standby setpoint + Low fan + optional `Quiet=1` in one GREE frame. If a unit rejects `Quiet`, the controller retries without it and remembers that capability fallback until restart; when demand resumes, controller-owned Quiet is released on the transition. - Toast progress indicators have horizontal inset so they stay inside rounded toast corners. - Settings can be exported/imported as JSON. Metrics, event logs and generated API-token records are preserved during import. Exported configuration contains configured secrets and must be protected. - Optional on-screen debug overlay is available on every Web UI route, can be changed through `/api/debug`, shows controller events plus live HTTP method/path/status timing and can include sanitized decrypted GREE request/response payloads. diff --git a/Cargo.lock b/Cargo.lock index e802ab4..8f57a1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.5.0" +version = "0.5.2" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 7e66a20..df9b5e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.5.0" +version = "0.5.2" 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 308aba5..cbcb835 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,16 +1,16 @@ 4989a37261ebad2b981c62ee9b5be41cc298f982e101782f27151774293f7a9b ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -2f6060e367a0289ee8029bc8d0486c2c28e54f990ae69816af7987ef5a5969ca ./BUILD_REPORT.md -6273e6e09d41e084d7bf6da83a8eeee17c89da9fa05ad248898c14558dfe098a ./Cargo.lock -d1bf838b0634706fdd4a533dfed712a45eb5167f9f675fbdcab2781baf2a1611 ./Cargo.toml +60e043828c8540fd46114141a01838f82e09aabe460ff1d6e7886a57d7c0cd33 ./BUILD_REPORT.md +161b2168c551e4e4563161ba2b1aa417a57332c984e3cf3f729fc5d6df987921 ./Cargo.lock +043fe7cc49e07a875d87ab8dfa6eef2bca73b6317038010c18765ea9950992cd ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -a0d2b6a9efc28f3359ca2bbbc72c562d18a8fceca2b7d90994f60f95366f833f ./README.md +975335835c4eae1f7a90ac903be39e4a1fe5c318c0d80ba71bfad225b2ed8488 ./README.md a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs 6577e178c03cdb5f9e5b55eba4c25e2733bd5ca48daa76cf7910cda776066517 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md 10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md -6309591814958f059f18ad7677f925a6f6e4a0eab4b780519fc7ed2067e98d56 ./docs/PROJECT_SPEC.md +47e18e6b1723f1f6eb2516be6d83bbebd31b8155d9d3cdfccc0973177f3d8441 ./docs/PROJECT_SPEC.md 3996549f159bb1bb07139cd5afa218d948326b192123a4e7a8ace15fe1e18be9 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py b8e7c0dd722bfac1d1d8d5c2a4b5ff6b2d6fa89525b9ff5c121ee1d09623a7fd ./home-assistant/custom_components/gree_controller/api.py @@ -19,7 +19,7 @@ fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assista 071f721b75af2ed68812c9be3a62b7ae63771e56e567a569f59ca976f8ff42f1 ./home-assistant/custom_components/gree_controller/const.py 8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py -0e9288c70c0e3d3b9944ac79f6fe227fdef1d18ed844c04ede2bbe2e1bc16962 ./home-assistant/custom_components/gree_controller/manifest.json +6d965c12b1dcfcc8beb439d2035fcb4e74e58db919682ce77b771729ea41e4a0 ./home-assistant/custom_components/gree_controller/manifest.json d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py 5c914c2faa18a9cb9eb442dc111c9416f89a630578f820beb021d5381aa1b2d0 ./home-assistant/custom_components/gree_controller/sensor.py 59632b92ed799234c632324b00e9a48e89ee06a5c49b8c27d29e495ee42e40e9 ./home-assistant/custom_components/gree_controller/switch.py @@ -27,8 +27,8 @@ d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -6e3489bedfea68bd0040686616cbfaab944182a1cebd0adf756b12469dcd925c ./lang/en.json -95039d760d81ec46208762e0cc34570a060e4f52a43fe58b5f95eafe826d4335 ./lang/pl.json +ea76d53c79adef54513e3d72c342c8b7c01e8a9ac13c56a19703c6f3642a6630 ./lang/en.json +14192dd2d83f8ccc8a4d36d54f76d8e941397b6d9b05e55edeb07083ec2426a3 ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 3640e34a1ed5a93be3e96610848b16b8e748d5ca95975ac7e95d38cbf32695a8 ./scripts/common.sh @@ -44,21 +44,21 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda 1e82b152e3e8badbceb2ffa6424fea027b7aad3ad87e8b1b46085caa66760ba8 ./src/api.rs 4bbbe739219730d7369965277e689b5a557202e2674869705009ca4c3239ad38 ./src/config.rs 1ec49d30ff14fc341bb15177ff195db41fb927d67114d1c1cb8ca671c7ea73ec ./src/db.rs -6359748b86a420334f5a090640773abb18ef4057f74342af32d5a250be5e0d4a ./src/engine.rs +d6088e1b36997a5bc40618501bcba87b69ae712ae841310d6c65d286b13a7c12 ./src/engine.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs 584a5dad5d52cafa0ab9d0883c501c7bc5d3126f515ace98a261a78a6d714c3a ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs 1936f06b4d9982024a676f97ce7d6ca225d5a8b13fd883d65ec100d8372b7bca ./src/main.rs eae77ed4aed700c72a0c1e6d672ffb0dd0fd6f50d0e7f6e1f8b8ab41cbf5df42 ./src/models.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs -a34005e60b1048d1fe7ed1457f4cd28055b486dbcc5d9da2886fdef5cf3f8c78 ./src/protocol/gree.rs +b76543136fb9c9742657a9b047629b7382d0e5d73ab468609c2be643e0681d25 ./src/protocol/gree.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs b6061a3325228fc5f70adb819d1b443e560d36964f98b8edb7c5bd079241348e ./src/queries.rs e3c02b232bd9d632ca1d3b6ad69a7cc61dc2e51954765d9e963d713d135f7dca ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -21b0a8e6f9852d5156d60268691134ef6e395814fa3a345b0ad6181471afd8bc ./web/app.js +d22d55f28e01cba9f25296ce601628dcbb62d202ee233a74304c3cdaec720192 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -7452a04bd4bcb7178f998c4f042976da6a532fcf14f0f90b43fccf4fbe3a4b43 ./web/index.html +1180d4044630ef04ce90da4c79104bcb02848c0156aba378ad21fbd51efc006e ./web/index.html c0e628423a82c5037b6d650aad5e8befe30945492c8cb0a5d94433db7b58c77b ./web/manifest.webmanifest -081dd439c49eee1f325a58c286a7b77a89642df9706a182b0bfa927702b9a876 ./web/styles.css +b54efeab2aa577b92df7842c9f2460a2a3ad054d83a4b4b0635ed6a21cd47cca ./web/styles.css 98bf333d3cb273ff7569c8985b459985ce956cbd5274ca4d48a634b578edf23b ./web/sw.js diff --git a/README.md b/README.md index 82cf403..09aa8a0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. -Current version: **0.5.0**. +Current version: **0.5.2**. ## Highlights @@ -42,7 +42,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do On Debian, Ubuntu or an LXC container: ```bash -unzip gree-controller-v0.5.0.zip +unzip gree-controller-v0.5.2.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index 5e23058..5cbcd33 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -75,7 +75,7 @@ During normal heating/cooling the controller minimizes unit power cycling. It ke - when the zone requires conditioning, use the active target (optionally assisted slightly by outdoor weather), - when the zone is satisfied, move the device setpoint to the non-demand side of the room target so the inverter/compressor can stop naturally, - use hysteresis and a minimum adjustment interval to avoid command chatter, -- optionally adjust fan speed based on room error and outdoor extremes. +- optionally adjust fan speed based on room error and outdoor extremes; with Smart fan enabled, a satisfied zone uses Low fan together with the standby setpoint and requests Quiet mode in the same frame when the unit supports it. Unsupported Quiet commands automatically fall back to Low fan without breaking thermostat control. Each zone has separate seasonal profile temperatures: diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 63e2652..f1a383f 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.5.0", + "version": "0.5.2", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 70dcd63..6e8df41 100644 --- a/lang/en.json +++ b/lang/en.json @@ -400,6 +400,49 @@ "debug.apiOnly": "API logs", "debug.empty": "No debug events yet.", "toast.exported": "Configuration exported", - "toast.imported": "Configuration imported" + "toast.imported": "Configuration imported", + "nav.simulation": "Simulator", + "simulation.eyebrow": "Simulator", + "simulation.title": "Automation simulator", + "simulation.description": "Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.", + "simulation.timelineEyebrow": "Timeline", + "simulation.timelineTitle": "What will happen next", + "simulation.zoneEyebrow": "Zones", + "simulation.zoneTitle": "Thermostat decision flow", + "simulation.rulesEyebrow": "Rules", + "simulation.rulesTitle": "Automation rules", + "simulation.houseMode": "House mode", + "simulation.strategy": "Strategy: {strategy}", + "simulation.outdoor": "Outdoor", + "simulation.generatedAt": "Generated: {time}", + "simulation.activeZones": "Active zones", + "simulation.requestingZones": "Requesting comfort: {count}", + "simulation.rulesLabel": "Active rules", + "simulation.eventsQueued": "Queued events: {count}", + "simulation.house": "House", + "simulation.stepRoom": "Room reading", + "simulation.stepTarget": "Target", + "simulation.stepDecision": "Decision", + "simulation.stepCommand": "Unit command", + "simulation.hysteresis": "Hysteresis {value}°C", + "simulation.deviceSetpoint": "Device setpoint", + "simulation.quietIfSupported": "If supported", + "simulation.manual": "Manual", + "simulation.noCommand": "No command preview", + "simulation.noActionPreview": "No direct action payload", + "simulation.ruleOnDevice": "Action on device: {device}", + "simulation.nextReady": "Ready again", + "simulation.noRules": "No active automation rules.", + "simulation.waitingForData": "Waiting for data", + "simulation.stateDemand": "Demand", + "simulation.stateSatisfied": "Satisfied", + "simulation.reasonDisabled": "Zone is disabled, so the thermostat does not send control commands.", + "simulation.reasonHouseOff": "The global house mode is off, so this zone stays inactive.", + "simulation.reasonWaiting": "The simulator is waiting for a valid room temperature and target.", + "simulation.reasonDemand": "Room needs {mode}. Thermostat compares the reading with the target {target}°C using hysteresis {hysteresis}°C.", + "simulation.reasonSatisfied": "Target is reached. The thermostat moves the unit into standby around {standby}°C to keep the room quiet and stable.", + "simulation.activeTargetLabel": "Active target before device rounding: {value}°C", + "simulation.standbyTargetLabel": "Standby target before device rounding: {value}°C", + "common.fan": "Fan" } } diff --git a/lang/pl.json b/lang/pl.json index 7d29d2a..1b0e695 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -400,6 +400,49 @@ "debug.apiOnly": "Logi API", "debug.empty": "Brak zdarzeń debug.", "toast.exported": "Konfiguracja wyeksportowana", - "toast.imported": "Konfiguracja zaimportowana" + "toast.imported": "Konfiguracja zaimportowana", + "nav.simulation": "Symulator", + "simulation.eyebrow": "Symulator", + "simulation.title": "Symulator automatyzacji", + "simulation.description": "Podgląd pokazuje, jak logika termostatu i automatyzacji interpretuje teraz każdą strefę: temperatury, cele, zapotrzebowanie, tryb podtrzymania i kolejne zdarzenia.", + "simulation.timelineEyebrow": "Oś czasu", + "simulation.timelineTitle": "Co wydarzy się dalej", + "simulation.zoneEyebrow": "Strefy", + "simulation.zoneTitle": "Przepływ decyzji termostatu", + "simulation.rulesEyebrow": "Reguły", + "simulation.rulesTitle": "Reguły automatyzacji", + "simulation.houseMode": "Tryb domu", + "simulation.strategy": "Strategia: {strategy}", + "simulation.outdoor": "Na zewnątrz", + "simulation.generatedAt": "Wygenerowano: {time}", + "simulation.activeZones": "Aktywne strefy", + "simulation.requestingZones": "Żądają komfortu: {count}", + "simulation.rulesLabel": "Aktywne reguły", + "simulation.eventsQueued": "Zdarzenia w kolejce: {count}", + "simulation.house": "Dom", + "simulation.stepRoom": "Pomiar w pokoju", + "simulation.stepTarget": "Cel", + "simulation.stepDecision": "Decyzja", + "simulation.stepCommand": "Komenda dla jednostki", + "simulation.hysteresis": "Histereza {value}°C", + "simulation.deviceSetpoint": "Nastawa jednostki", + "simulation.quietIfSupported": "Jeśli obsługiwane", + "simulation.manual": "Ręcznie", + "simulation.noCommand": "Brak podglądu komendy", + "simulation.noActionPreview": "Brak bezpośredniej akcji", + "simulation.ruleOnDevice": "Akcja na urządzeniu: {device}", + "simulation.nextReady": "Gotowa ponownie", + "simulation.noRules": "Brak aktywnych reguł automatyzacji.", + "simulation.waitingForData": "Oczekiwanie na dane", + "simulation.stateDemand": "Zapotrzebowanie", + "simulation.stateSatisfied": "Osiągnięto cel", + "simulation.reasonDisabled": "Strefa jest wyłączona, więc termostat nie wysyła komend sterujących.", + "simulation.reasonHouseOff": "Globalny tryb domu jest wyłączony, więc ta strefa pozostaje nieaktywna.", + "simulation.reasonWaiting": "Symulator czeka na poprawny odczyt temperatury pokojowej i cel.", + "simulation.reasonDemand": "Pomieszczenie potrzebuje trybu {mode}. Termostat porównuje odczyt z celem {target}°C i histerezą {hysteresis}°C.", + "simulation.reasonSatisfied": "Cel został osiągnięty. Termostat przełącza jednostkę w podtrzymanie około {standby}°C, aby utrzymać ciszę i stabilność.", + "simulation.activeTargetLabel": "Aktywny cel przed zaokrągleniem: {value}°C", + "simulation.standbyTargetLabel": "Cel podtrzymania przed zaokrągleniem: {value}°C", + "common.fan": "Wentylator" } } diff --git a/src/engine.rs b/src/engine.rs index 1e4ab86..3768f18 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -102,8 +102,9 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm if command.is_empty() { return Ok(device); } let suppress_beep = state.settings.read().await.suppress_device_beep; + let mut applied_command = command.clone(); if device.simulated { - command.apply(&mut device); + applied_command.apply(&mut device); device.online = true; device.last_seen = Some(Utc::now()); device.last_error = None; @@ -124,24 +125,30 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm } } } - if let Err(first_err) = state.gree.command(&device, &command, suppress_beep).await { - // Retry once after a fresh bind. This covers stale keys and devices that - // switched between ECB/GCM after a firmware update. - let retry_result = match state.gree.bind(&device).await { - Ok(bound) => { - device.key = Some(bound.key); - device.protocol_version = bound.protocol_version; - state.db.save_device(&device)?; - state.gree.command(&device, &command, suppress_beep).await + match state.gree.command(&device, &command, suppress_beep).await { + Ok(result) => applied_command = result, + Err(first_err) => { + // Retry once after a fresh bind. This covers stale keys and devices that + // switched between ECB/GCM after a firmware update. + let retry_result = match state.gree.bind(&device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + state.db.save_device(&device)?; + state.gree.command(&device, &command, suppress_beep).await + } + Err(_) => Err(first_err), + }; + match retry_result { + Ok(result) => applied_command = result, + Err(err) => { + register_device_failure(state, &mut device, &err.to_string())?; + return Err(AppError::Device(err.to_string())); + } } - Err(_) => Err(first_err), - }; - if let Err(err) = retry_result { - register_device_failure(state, &mut device, &err.to_string())?; - return Err(AppError::Device(err.to_string())); } } - command.apply(&mut device); + applied_command.apply(&mut device); device.online = true; device.communication_failures = 0; device.last_seen = Some(Utc::now()); @@ -151,7 +158,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm state.log("info", "device.command", &format!("Updated {}", device.name), json!({ "device_id": device.id, - "command": command, + "command": applied_command, })); state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); Ok(device) @@ -424,6 +431,7 @@ async fn control_zones(state: &AppState) -> Result<()> { }; let half = zone.hysteresis.max(0.1) / 2.0; + let previous_demand = zone.demand; zone.demand = match effective_mode { "heat" => { if temp <= target - half { true } @@ -456,11 +464,23 @@ async fn control_zones(state: &AppState) -> Result<()> { } else { None }; + // When the room becomes satisfied, ask compatible units for Quiet in the same + // frame as the standby setpoint and Low fan. When demand returns, disable Quiet + // only on that transition so a user's manual Quiet choice is not constantly + // overwritten while the zone is actively heating/cooling. + let desired_quiet = smart_quiet_command( + zone.smart_fan, + state.gree.quiet_command_supported(&device.id), + previous_demand, + zone.demand, + device.quiet, + ); let needs_command = !device.power || device.mode != effective_mode || (device.target_temperature - desired_device_target).abs() >= 0.5 - || desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false); + || desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false) + || desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false); let urgent_mode_change = !device.power || device.mode != effective_mode; if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) { @@ -469,10 +489,11 @@ async fn control_zones(state: &AppState) -> Result<()> { mode: Some(effective_mode.to_string()), target_temperature: Some(desired_device_target), fan_speed: desired_fan, + quiet: desired_quiet, ..Default::default() }; match send_command(state, &zone.device_id, command).await { - Ok(_) => { + Ok(updated_device) => { zone.last_action_at = Some(Utc::now()); state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({ "zone_id": zone.id, @@ -482,6 +503,8 @@ async fn control_zones(state: &AppState) -> Result<()> { "mode": effective_mode, "preset": zone.active_preset, "outdoor_temperature": outdoor_temperature, + "fan_speed": updated_device.fan_speed, + "quiet": updated_device.quiet, })); } Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), @@ -640,8 +663,24 @@ fn outdoor_assist_offset(mode: &str, outdoor: Option, room: f64, target: f6 (weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0) } +fn smart_quiet_command( + smart_fan: bool, + quiet_supported: bool, + previous_demand: bool, + demand: bool, + device_quiet: bool, +) -> Option { + if !smart_fan || !quiet_supported { return None; } + if !demand { return Some(true); } + if !previous_demand && device_quiet { return Some(false); } + None +} + fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option, demand: bool) -> u8 { - if !demand { return 0; } + // When the thermostat is satisfied, keep airflow quiet instead of leaving the + // unit in Auto. The caller sends this together with the standby setpoint in + // the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating. + if !demand { return 1; } let error = (room - target).abs(); let extreme_weather = match (mode, outdoor) { ("heat", Some(value)) => value <= 0.0, @@ -1023,6 +1062,21 @@ mod tests { assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0); } + #[test] + fn smart_fan_uses_low_speed_when_zone_is_satisfied() { + assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1); + assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1); + } + + #[test] + fn smart_quiet_follows_satisfied_transition_only_when_supported() { + assert_eq!(smart_quiet_command(true, true, true, false, false), Some(true)); + assert_eq!(smart_quiet_command(true, true, false, true, true), Some(false)); + assert_eq!(smart_quiet_command(true, true, true, true, true), None); + assert_eq!(smart_quiet_command(true, false, true, false, false), None); + assert_eq!(smart_quiet_command(false, true, true, false, false), None); + } + #[test] fn outdoor_assist_is_bounded_and_direction_neutral() { let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0); diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index 8c766fb..48c922e 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -23,6 +23,7 @@ pub struct GreeClient { debug_events: Option>, debug_gree_frames: Arc, buzzer_unsupported: Arc>>, + quiet_unsupported: Arc>>, } impl GreeClient { @@ -38,6 +39,7 @@ impl GreeClient { debug_events, debug_gree_frames, buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())), + quiet_unsupported: Arc::new(Mutex::new(HashSet::new())), } } @@ -414,17 +416,25 @@ impl GreeClient { Ok(()) } - pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result { - let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; + pub fn quiet_command_supported(&self, device_id: &str) -> bool { + self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) + } + + async fn request_command_with_buzzer_fallback( + &self, + device: &Device, + key: &str, + command: &DeviceCommand, + suppress_beep: bool, + ) -> Result { let try_buzzer_suppression = suppress_beep && self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true); let inner = Self::command_payload(command, try_buzzer_suppression)?; match self.request(device, &inner, key, false, device.protocol_version).await { Ok(value) => Ok(value), Err(first_err) if try_buzzer_suppression => { - // Some firmwares reject unknown command properties instead of ignoring them. - // Retry the exact state change without buzzer fields; only remember the device - // as incompatible after that fallback succeeds. + // Some firmwares reject unknown buzzer properties instead of ignoring them. + // Retry the exact state change without buzzer fields and remember the fallback. let fallback = Self::command_payload(command, false)?; match self.request(device, &fallback, key, false, device.protocol_version).await { Ok(value) => { @@ -439,6 +449,38 @@ impl GreeClient { } } + pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result { + let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; + let mut effective = command.clone(); + if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) { + effective.quiet = None; + } + if effective.is_empty() { + return Ok(effective); + } + + match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await { + Ok(_) => Ok(effective), + Err(first_err) if effective.quiet.is_some() => { + // Quiet is not implemented by every GREE indoor unit. If a combined thermostat + // command is rejected, retry the same setpoint/fan change without Quiet. Once + // that succeeds, remember the unit so future thermostat frames omit Quiet. + let mut fallback = effective.clone(); + fallback.quiet = None; + if fallback.is_empty() { return Err(first_err); } + match self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await { + Ok(_) => { + if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } + tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will use Low fan without Quiet for this device"); + Ok(fallback) + } + Err(_) => Err(first_err), + } + } + Err(err) => Err(err), + } + } + fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result { let mut opt = Vec::<&str>::new(); let mut values = Vec::::new(); @@ -683,3 +725,21 @@ pub fn merge_discovered(existing: Option, discovered: Device) -> Device new } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thermostat_standby_setpoint_low_fan_and_quiet_share_one_frame() { + let payload = GreeClient::command_payload(&DeviceCommand { + target_temperature: Some(19.0), + fan_speed: Some(1), + quiet: Some(true), + ..DeviceCommand::default() + }, false).expect("thermostat command payload"); + + assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet"]))); + assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1]))); + } +} diff --git a/web/app.js b/web/app.js index d6a83c8..f0ddcd8 100644 --- a/web/app.js +++ b/web/app.js @@ -194,6 +194,7 @@ function renderAll() { renderSummary(); renderHouseClimate(); renderControlPlan(); + renderSimulationPage(); renderDevices(); renderZones(); renderSchedules(); @@ -267,10 +268,179 @@ function renderControlPlan() { } async function loadControlPlan() { - try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); } + try { app.controlPlan = await api('/api/control-plan'); renderControlPlan(); renderSimulationPage(); } catch (error) { console.warn('Unable to load control plan:', error); } } +function simulationTime(value, options = {}) { + if (!value) return '—'; + const date = new Date(value); + return date.toLocaleString(locale(), {weekday: options.withDay === false ? undefined : 'short', hour:'2-digit', minute:'2-digit'}).replace(',', ''); +} + +function simulationNumeric(value) { + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function simulationOutdoorAssist(mode, outdoor, room, target) { + if (outdoor == null || room == null || target == null) return 0; + const roomError = Math.abs(room - target); + const weather = mode === 'heat' ? clamp((5 - outdoor) / 15, 0, 1) : clamp((outdoor - 30) / 10, 0, 1); + return clamp(weather * clamp(roomError, 0, 2) * 0.5, 0, 1); +} + +function simulationRoundDeviceSetpoint(mode, demand, value) { + const safe = clamp(Number(value) || 0, 16, 30); + if (mode === 'heat' && demand) return Math.ceil(safe); + if (mode === 'heat' && !demand) return Math.floor(safe); + if (mode !== 'heat' && demand) return Math.floor(safe); + return Math.ceil(safe); +} + +function simulationSmartFanSpeed(mode, room, target, outdoor, demand) { + if (!demand) return 1; + const error = Math.abs(room - target); + const extremeWeather = mode === 'heat' ? outdoor != null && outdoor <= 0 : outdoor != null && outdoor >= 32; + if (error >= 2 || extremeWeather) return 3; + if (error >= 1) return 2; + return 0; +} + +function simulationStatusInfo(status) { + const map = { + disabled: {label: tr('common.disabled'), badge: ''}, + off: {label: tr('mode.off'), badge: ''}, + waiting: {label: tr('simulation.waitingForData'), badge: ''}, + demand: {label: tr('simulation.stateDemand'), badge: 'active'}, + satisfied: {label: tr('simulation.stateSatisfied'), badge: ''}, + }; + return map[status] || {label: status, badge: ''}; +} + +function buildZoneSimulation(zone, planZone) { + const device = app.devices.find(item => item.id === (planZone?.device_id || zone?.device_id)); + const mode = planZone?.mode || zone?.effective_mode || zone?.mode || app.settings?.house_mode || 'off'; + const current = simulationNumeric(planZone?.current_temperature ?? zone?.current_temperature ?? zone?.device_temperature ?? device?.current_temperature); + const target = simulationNumeric(planZone?.target_temperature ?? zone?.effective_setpoint ?? zone?.manual_setpoint ?? zone?.setpoint); + const outdoor = simulationNumeric(app.outdoorTemperature ?? app.controlPlan?.outdoor_temperature); + const hysteresis = Math.max(simulationNumeric(zone?.hysteresis) || 0.4, 0.1); + const half = hysteresis / 2; + let status = 'waiting'; + let demand = false; + if (!zone?.enabled || planZone?.enabled === false) status = 'disabled'; + else if (mode === 'off') status = 'off'; + else if (current == null || target == null) status = 'waiting'; + else { + if (mode === 'heat') { + if (current <= target - half) demand = true; + else if (current >= target + half) demand = false; + else demand = !!zone?.demand; + } else { + if (current >= target + half) demand = true; + else if (current <= target - half) demand = false; + else demand = !!zone?.demand; + } + status = demand ? 'demand' : 'satisfied'; + } + const standbyOffset = Math.max(simulationNumeric(zone?.standby_offset_c) || 0.5, 0.5); + const assist = current == null || target == null ? 0 : simulationOutdoorAssist(mode, outdoor, current, target); + const activeTarget = current == null || target == null ? null : (mode === 'heat' ? target + assist : target - assist); + const standbyTarget = target == null ? null : (mode === 'heat' ? target - standbyOffset : target + standbyOffset); + const desiredDeviceTarget = current == null || target == null ? null : simulationRoundDeviceSetpoint(mode, demand, demand ? activeTarget : standbyTarget); + const fanSpeed = zone?.smart_fan && current != null && target != null ? simulationSmartFanSpeed(mode, current, target, outdoor, demand) : simulationNumeric(device?.fan_speed); + const quiet = zone?.smart_fan ? (!demand ? tr('simulation.quietIfSupported') : tr('common.off')) : tr('simulation.manual'); + const reasoning = !zone?.enabled || planZone?.enabled === false + ? tr('simulation.reasonDisabled') + : mode === 'off' + ? tr('simulation.reasonHouseOff') + : current == null || target == null + ? tr('simulation.reasonWaiting') + : demand + ? tr('simulation.reasonDemand', {mode: modeLabel(mode), target: target.toFixed(1), hysteresis: hysteresis.toFixed(1)}) + : tr('simulation.reasonSatisfied', {target: target.toFixed(1), standby: standbyTarget.toFixed(1)}); + return {device, mode, current, target, demand, status, outdoor, hysteresis, assist, activeTarget, standbyTarget, desiredDeviceTarget, fanSpeed, quiet, reasoning}; +} + +function simulationRuleAction(rule) { + const bits = []; + if (rule.action?.power != null) bits.push(`${tr('common.power')}: ${rule.action.power ? tr('common.on') : tr('common.off')}`); + if (rule.action?.mode) bits.push(`${tr('common.mode')}: ${modeLabel(rule.action.mode)}`); + if (rule.action?.target_temperature != null) bits.push(`${tr('common.temperature')}: ${fmtTemp(rule.action.target_temperature)}`); + return bits.length ? bits.join(' · ') : tr('simulation.noActionPreview'); +} + +function renderSimulationPage() { + const summaryHost = $('#simulationSummary'); + const timelineHost = $('#simulationTimeline'); + const zonesHost = $('#simulationZones'); + const rulesHost = $('#simulationRules'); + if (!summaryHost || !timelineHost || !zonesHost || !rulesHost) return; + const plan = app.controlPlan; + if (!plan) { + summaryHost.innerHTML = `
${esc(tr('plan.loading'))}
`; + timelineHost.innerHTML = ''; + zonesHost.innerHTML = ''; + rulesHost.innerHTML = ''; + return; + } + const enabledZones = (plan.zones || []).filter(zone => zone.enabled); + const demandingZones = enabledZones.filter(zone => zone.demand); + summaryHost.innerHTML = [ + {label: tr('simulation.houseMode'), value: modeLabel(plan.house_mode || 'off'), note: tr('simulation.strategy', {strategy: plan.control_strategy || 'setpoint'})}, + {label: tr('simulation.outdoor'), value: plan.outdoor_temperature == null ? '—' : fmtTemp(plan.outdoor_temperature), note: tr('simulation.generatedAt', {time: dateTime(plan.generated_at)})}, + {label: tr('simulation.activeZones'), value: String(enabledZones.length), note: tr('simulation.requestingZones', {count: demandingZones.length})}, + {label: tr('simulation.rulesLabel'), value: String((plan.rules || []).filter(rule => rule.enabled).length), note: tr('simulation.eventsQueued', {count: (plan.next_events || []).length})}, + ].map(card => `
${esc(card.label)}${esc(card.value)}${esc(card.note)}
`).join(''); + + const timelineItems = []; + (plan.next_events || []).forEach(event => timelineItems.push({scope: tr('simulation.house'), event})); + (plan.zones || []).forEach(zone => (zone.next_events || []).forEach(event => timelineItems.push({scope: zone.zone_name, event, device: zone.device_name}))); + timelineItems.sort((a, b) => new Date(a.event.at) - new Date(b.event.at)); + const unique = []; + const seen = new Set(); + for (const item of timelineItems) { + const key = `${item.scope}|${item.event.at}|${item.event.label}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(item); + if (unique.length >= 16) break; + } + timelineHost.innerHTML = unique.length ? unique.map(item => { + const target = item.event.target_temperature == null ? '' : ` · ${fmtTemp(item.event.target_temperature)}`; + return `
${esc(item.scope)}

${esc(item.event.label)}${esc(target)}${item.device ? ` · ${esc(item.device)}` : ''}

`; + }).join('') : `
${esc(tr('plan.noEvents'))}
`; + + zonesHost.innerHTML = (plan.zones || []).length ? plan.zones.map(planZone => { + const zone = app.zones.find(item => item.id === planZone.zone_id); + const sim = buildZoneSimulation(zone, planZone); + const status = simulationStatusInfo(sim.status); + const events = (planZone.next_events || []).slice(0, 4); + const sourceLabel = zoneControlSourceLabel(planZone.control_source); + const fanText = sim.fanSpeed == null ? '—' : fanLabel(sim.fanSpeed); + const commandText = sim.desiredDeviceTarget == null ? tr('simulation.noCommand') : `${fmtTemp(sim.desiredDeviceTarget)} · ${fanText} · ${tr('devices.quiet')}: ${sim.quiet}`; + return `
+
${esc(planZone.device_name || tr('common.noDevice'))}

${esc(planZone.zone_name)}

${esc(modeLabel(sim.mode))} · ${esc(zonePresetLabel(planZone.preset))}${planZone.current_schedule_name ? ` · ${esc(planZone.current_schedule_name)}` : ''}

${esc(status.label)}
+
+
${esc(tr('simulation.stepRoom'))}${esc(fmtTemp(sim.current))}${esc(sourceLabel)}
+
${esc(tr('simulation.stepTarget'))}${esc(sim.target == null ? '—' : `${sim.target.toFixed(1)}°C`)}${esc(tr('simulation.hysteresis', {value: sim.hysteresis.toFixed(1)}))}
+
${esc(tr('simulation.stepDecision'))}${esc(status.label)}${esc(sim.reasoning)}
+
${esc(tr('simulation.stepCommand'))}${esc(commandText)}${esc(sim.demand ? tr('simulation.activeTargetLabel', {value: sim.activeTarget == null ? '—' : sim.activeTarget.toFixed(1)}) : tr('simulation.standbyTargetLabel', {value: sim.standbyTarget == null ? '—' : sim.standbyTarget.toFixed(1)}))}
+
+
+
${esc(tr('simulation.deviceSetpoint'))}${esc(sim.desiredDeviceTarget == null ? '—' : fmtTemp(sim.desiredDeviceTarget))}
+
${esc(tr('common.fan'))}${esc(fanText)}
+
${esc(tr('devices.quiet'))}${esc(sim.quiet)}
+
${esc(tr('simulation.outdoor'))}${esc(sim.outdoor == null ? '—' : fmtTemp(sim.outdoor))}
+
+
    ${events.length ? events.map(planEventMarkup).join('') : `
  • ${esc(tr('plan.noEvents'))}
  • `}
+
`; + }).join('') : `
${esc(tr('zones.emptyText'))}
`; + + const activeRules = (plan.rules || []).filter(rule => rule.enabled); + rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `
${esc(tr('common.automation'))}

${esc(rule.name)}

${esc(automationTriggerLabel(rule))}

${esc(tr('simulation.ruleOnDevice', {device: rule.action_device_name || tr('common.noDevice')}))}

${esc(simulationRuleAction(rule))}
${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}
`).join('') : `
${esc(tr('simulation.noRules'))}
`; +} + function scheduleControlPlanLoad() { clearTimeout(app.controlPlanTimer); app.controlPlanTimer = setTimeout(loadControlPlan, 180); @@ -485,7 +655,7 @@ function formatDuration(seconds) { return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`; } -const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', settings:'/settings', logs:'/events'}; +const VIEW_ROUTES = {dashboard:'/dashboard', devices:'/devices', zones:'/zones', schedules:'/schedules', automations:'/automations', simulation:'/simulation', settings:'/settings', logs:'/events'}; const HISTORY_TABS = ['overview','zones','devices','sensors','custom']; function currentHistoryPath() { @@ -510,7 +680,7 @@ function updateBrowserUrl(path, replace=false) { function showView(name, {push=true, scroll=true}={}) { app.currentView = name; $$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name)); - $$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name)))); + $$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','simulation','settings','logs'].includes(name)))); if (push) updateBrowserUrl(name === 'history' ? currentHistoryPath() : (VIEW_ROUTES[name] || '/dashboard')); if (scroll) window.scrollTo({top: 0, behavior: 'smooth'}); if (name === 'history') { renderHistoryNavigation(); loadHistory(); } @@ -1264,6 +1434,11 @@ $('#haTest').addEventListener('click', async () => { } catch(error){toast(error.message,true);} }); +$('#simulationRefresh')?.addEventListener('click', async () => { + try { await loadControlPlan(); toast(tr('common.updated')); } + catch (error) { toast(error.message, true); } +}); + $('#exportSettings').addEventListener('click', async () => { try { const data = await api('/api/settings/export'); diff --git a/web/index.html b/web/index.html index ef6422b..9269199 100644 --- a/web/index.html +++ b/web/index.html @@ -91,6 +91,20 @@
+
+
Simulator

Automation simulator

+

Preview how the thermostat and automation logic currently reason about each zone: temperatures, targets, demand, standby behavior and the next scheduled events.

+
+
+
Timeline

What will happen next

+
+
+
Zones

Thermostat decision flow

+
+
Rules

Automation rules

+
+
+
Measurements

Climate history

@@ -204,6 +218,7 @@ diff --git a/web/styles.css b/web/styles.css index 2f07879..98e2456 100644 --- a/web/styles.css +++ b/web/styles.css @@ -461,3 +461,48 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; } } .history-chart-card canvas { width: 100%; min-width: 720px; } + +.simulation-summary-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap:12px; margin-bottom:16px; } +.simulation-summary-card { display:grid; gap:6px; padding:16px; } +.simulation-summary-card small { color:var(--muted); font-size:12px; } +.simulation-summary-card strong { font-size:28px; letter-spacing:-.04em; } +.simulation-summary-card span { color:var(--muted); font-size:12px; line-height:1.45; } +.simulation-section-head { display:flex; align-items:end; justify-content:space-between; margin:24px 0 12px; } +.simulation-section-head h2 { margin:4px 0 0; } +.simulation-timeline-panel { padding:16px; margin-bottom:22px; } +.simulation-timeline { display:grid; gap:10px; } +.simulation-timeline-item { display:grid; grid-template-columns:110px 1fr; gap:12px; align-items:start; padding:12px 0; border-bottom:1px solid var(--line); } +.simulation-timeline-item:last-child { border-bottom:0; padding-bottom:0; } +.simulation-timeline-item time { color:var(--muted); font-size:12px; } +.simulation-timeline-item strong { display:block; margin-bottom:4px; } +.simulation-timeline-item p { margin:0; color:var(--muted); line-height:1.45; } +.simulation-zone-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 380px), 1fr)); gap:14px; } +.simulation-zone-card { display:grid; gap:14px; padding:16px; } +.simulation-zone-card.disabled { opacity:.68; } +.simulation-zone-head, .simulation-rule-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; } +.simulation-zone-head h3, .simulation-rule-head h3 { margin:3px 0 0; } +.simulation-zone-head p, .simulation-rule-head p { margin:6px 0 0; color:var(--muted); font-size:13px; line-height:1.45; } +.simulation-flow { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; } +.simulation-step { display:grid; gap:6px; padding:12px; border:1px solid var(--line); border-radius:16px; background:rgba(255,255,255,.02); min-height:112px; } +html[data-theme='light'] .simulation-step { background:rgba(0,0,0,.02); } +.simulation-step.accent { box-shadow:inset 0 0 0 1px rgba(101,163,255,.28); } +.simulation-step small, .simulation-metrics small, .simulation-rule-meta small { color:var(--muted); font-size:11px; } +.simulation-step strong { font-size:15px; line-height:1.35; } +.simulation-step span { color:var(--muted); font-size:12px; line-height:1.4; } +.simulation-metrics { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:10px; } +.simulation-metrics > div, .simulation-rule-action { padding:12px; border-radius:14px; border:1px solid var(--line); background:rgba(255,255,255,.02); } +html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .simulation-rule-action { background:rgba(0,0,0,.02); } +.simulation-metrics strong { display:block; margin-top:4px; } +.simulation-events { padding-top:0; border-top:0; } +.simulation-rules-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap:14px; } +.simulation-rule-card { display:grid; gap:12px; padding:16px; } +.simulation-rule-card > p { margin:0; color:var(--muted); } +.simulation-rule-action { font-size:14px; line-height:1.5; } +.simulation-rule-meta { display:flex; flex-wrap:wrap; gap:12px; color:var(--muted); } +@media (max-width: 900px) { + .simulation-flow, .simulation-metrics { grid-template-columns:repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 640px) { + .simulation-timeline-item { grid-template-columns:1fr; gap:6px; } + .simulation-flow, .simulation-metrics { grid-template-columns:1fr; } +}