diff --git a/Cargo.lock b/Cargo.lock index 421a135..8ca3174 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,12 +50,27 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -84,6 +99,31 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -93,6 +133,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -105,6 +161,16 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "euclid" version = "0.22.14" @@ -152,6 +218,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -291,6 +367,7 @@ dependencies = [ "serde", "serde_json", "time", + "tungstenite", "ureq", ] @@ -410,6 +487,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -456,6 +539,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -486,6 +578,36 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "ring" version = "0.17.14" @@ -497,7 +619,7 @@ dependencies = [ "getrandom", "libc", "untrusted", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -521,6 +643,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -541,6 +685,15 @@ dependencies = [ "untrusted", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "sdl2" version = "0.38.0" @@ -565,6 +718,29 @@ dependencies = [ "version-compare", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -592,7 +768,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -608,6 +784,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -666,6 +853,17 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.9" @@ -677,6 +875,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "time" version = "0.3.55" @@ -726,6 +944,33 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -788,6 +1033,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -800,6 +1051,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -815,6 +1072,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -824,6 +1087,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -900,6 +1172,26 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.1" diff --git a/Cargo.toml b/Cargo.toml index 2b90848..7840ce8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ sdl2 = { version = "0.38", features = ["use-pkgconfig"] } serde = { version = "1", features = ["derive"] } serde_json = "1" time = { version = "0.3", features = ["local-offset", "formatting", "parsing"] } +tungstenite = { version = "0.24", default-features = false, features = ["handshake", "rustls-tls-native-roots"] } ureq = { version = "3.4", default-features = false, features = ["rustls"] } [profile.release] diff --git a/src/ha_client.rs b/src/ha_client.rs new file mode 100644 index 0000000..577c3a8 --- /dev/null +++ b/src/ha_client.rs @@ -0,0 +1,465 @@ +//! Home Assistant WebSocket client running on a dedicated background thread. +//! Follows the same mpsc/thread pattern as weather-service.rs — no async runtime. +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +const RECONNECT_DELAY: Duration = Duration::from_secs(5); +const PING_INTERVAL: Duration = Duration::from_secs(30); +const READ_TIMEOUT: Duration = Duration::from_secs(45); + +// ── Public types ────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct EntityState { + pub entity_id: String, + pub state: String, + pub attributes: Value, + pub last_changed: String, +} + +#[derive(Clone, Debug)] +pub struct HistoryPoint { + pub timestamp: f64, + pub value: f64, +} + +pub enum HaResponse { + Connected, + Disconnected(String), + StateChanged(EntityState), + AllStates(Vec), + History { + entity_id: String, + points: Vec, + }, + ServiceResult { + id: u64, + success: bool, + }, +} + +pub enum HaCommand { + CallService { + id: u64, + domain: String, + service: String, + target: Value, + data: Value, + }, + FetchHistory { + entity_id: String, + hours: u32, + }, +} + +// ── Config ──────────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +pub struct HaConfig { + pub url: String, + pub token: String, + pub entity_ids: Vec, +} + +// ── Service (main-thread handle) ────────────────────────────────────── + +pub struct HaService { + pub receiver: mpsc::Receiver, + commands: mpsc::Sender, + closed: Arc, + worker: Option>, +} + +impl HaService { + pub fn new(config: HaConfig) -> Self { + let (resp_tx, resp_rx) = mpsc::channel(); + let (cmd_tx, cmd_rx) = mpsc::channel(); + let closed = Arc::new(AtomicBool::new(false)); + let closed_flag = closed.clone(); + + let worker = thread::spawn(move || { + worker_loop(config, resp_tx, cmd_rx, closed_flag); + }); + + Self { + receiver: resp_rx, + commands: cmd_tx, + closed, + worker: Some(worker), + } + } + + pub fn send(&self, command: HaCommand) { + let _ = self.commands.send(command); + } +} + +impl Drop for HaService { + fn drop(&mut self) { + self.closed.store(true, Ordering::SeqCst); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +// ── Worker thread ───────────────────────────────────────────────────── + +fn worker_loop( + config: HaConfig, + responses: mpsc::Sender, + commands: mpsc::Receiver, + closed: Arc, +) { + while !closed.load(Ordering::SeqCst) { + match connect_and_run(&config, &responses, &commands, &closed) { + Ok(()) => break, + Err(e) => { + eprintln!("HA WebSocket: {e}"); + let _ = responses.send(HaResponse::Disconnected(e)); + if closed.load(Ordering::SeqCst) { + break; + } + thread::sleep(RECONNECT_DELAY); + } + } + } +} + +fn connect_and_run( + config: &HaConfig, + responses: &mpsc::Sender, + commands: &mpsc::Receiver, + closed: &Arc, +) -> Result<(), String> { + use tungstenite::{Message, connect}; + + let (mut ws, _) = + connect(&config.url).map_err(|e| format!("connect failed: {e}"))?; + + match ws.get_ref() { + tungstenite::stream::MaybeTlsStream::Plain(tcp) => { + tcp.set_read_timeout(Some(READ_TIMEOUT)).ok(); + } + tungstenite::stream::MaybeTlsStream::Rustls(tls) => { + tls.get_ref().set_read_timeout(Some(READ_TIMEOUT)).ok(); + } + _ => {} + } + + let mut msg_id: u64 = 0; + let mut next_id = || { + msg_id += 1; + msg_id + }; + + // Phase 1: auth_required + let auth_required = read_json(&mut ws)?; + if auth_required.get("type").and_then(Value::as_str) != Some("auth_required") { + return Err(format!("expected auth_required, got: {auth_required}")); + } + + // Phase 2: authenticate + ws_send( + &mut ws, + &json!({ "type": "auth", "access_token": config.token }), + )?; + + let auth_result = read_json(&mut ws)?; + match auth_result.get("type").and_then(Value::as_str) { + Some("auth_ok") => {} + Some("auth_invalid") => { + return Err(format!( + "auth failed: {}", + auth_result + .get("message") + .and_then(Value::as_str) + .unwrap_or("invalid token") + )); + } + _ => return Err(format!("unexpected auth response: {auth_result}")), + } + + let _ = responses.send(HaResponse::Connected); + + // Phase 3: get all states + let states_id = next_id(); + ws_send(&mut ws, &json!({ "id": states_id, "type": "get_states" }))?; + + // Phase 4: subscribe to state changes + let sub_id = next_id(); + ws_send( + &mut ws, + &json!({ + "id": sub_id, + "type": "subscribe_events", + "event_type": "state_changed" + }), + )?; + + // Pending service call / history requests mapped by HA message id + let mut pending_service: HashMap = HashMap::new(); // ha_id -> caller_id + let mut pending_history: HashMap = HashMap::new(); // ha_id -> entity_id + + let mut last_ping = Instant::now(); + + loop { + if closed.load(Ordering::SeqCst) { + let _ = ws.close(None); + return Ok(()); + } + + // Drain commands from the main thread + while let Ok(cmd) = commands.try_recv() { + match cmd { + HaCommand::CallService { + id: caller_id, + domain, + service, + target, + data, + } => { + let ha_id = next_id(); + pending_service.insert(ha_id, caller_id); + ws_send( + &mut ws, + &json!({ + "id": ha_id, + "type": "call_service", + "domain": domain, + "service": service, + "target": target, + "service_data": data, + }), + )?; + } + HaCommand::FetchHistory { entity_id, hours } => { + let ha_id = next_id(); + pending_history.insert(ha_id, entity_id.clone()); + let now = time::OffsetDateTime::now_utc(); + let start = now - time::Duration::hours(hours as i64); + let format = + time::format_description::well_known::Rfc3339; + let start_str = start.format(&format).unwrap_or_default(); + let end_str = now.format(&format).unwrap_or_default(); + ws_send( + &mut ws, + &json!({ + "id": ha_id, + "type": "recorder/statistics_during_period", + "start_time": start_str, + "end_time": end_str, + "statistic_ids": [entity_id], + "period": "5minute", + "types": ["mean"], + }), + )?; + } + } + } + + // Send ping if needed + if last_ping.elapsed() >= PING_INTERVAL { + let ping_id = next_id(); + ws_send(&mut ws, &json!({ "id": ping_id, "type": "ping" }))?; + last_ping = Instant::now(); + } + + // Read one message (with timeout set on the socket) + let msg = match ws.read() { + Ok(msg) => msg, + Err(tungstenite::Error::Io(ref e)) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + continue; + } + Err(e) => return Err(format!("read error: {e}")), + }; + + let text = match msg { + Message::Text(t) => t, + Message::Ping(data) => { + let _ = ws.send(Message::Pong(data)); + continue; + } + Message::Close(_) => return Err("server closed connection".into()), + _ => continue, + }; + + let value: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => continue, + }; + + let msg_type = value.get("type").and_then(Value::as_str).unwrap_or(""); + let id = value.get("id").and_then(Value::as_u64).unwrap_or(0); + + match msg_type { + "result" if id == states_id => { + if let Some(result) = value.get("result").and_then(Value::as_array) { + let states: Vec = result + .iter() + .filter_map(parse_entity_state) + .filter(|e| config.entity_ids.contains(&e.entity_id)) + .collect(); + let _ = responses.send(HaResponse::AllStates(states)); + } + } + "event" => { + if let Some(event_data) = value + .get("event") + .and_then(|e| e.get("data")) + .and_then(|d| d.get("new_state")) + { + if let Some(entity) = parse_entity_state(event_data) { + if config.entity_ids.contains(&entity.entity_id) { + let _ = responses.send(HaResponse::StateChanged(entity)); + } + } + } + } + "result" if pending_service.contains_key(&id) => { + if let Some(caller_id) = pending_service.remove(&id) { + let success = value + .get("success") + .and_then(Value::as_bool) + .unwrap_or(false); + let _ = responses.send(HaResponse::ServiceResult { + id: caller_id, + success, + }); + } + } + "result" if pending_history.contains_key(&id) => { + if let Some(entity_id) = pending_history.remove(&id) { + let points = parse_statistics(&value, &entity_id); + let _ = responses.send(HaResponse::History { + entity_id, + points, + }); + } + } + "pong" => {} + _ => {} + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────── + +type WsStream = tungstenite::WebSocket>; + +fn ws_send(ws: &mut WsStream, value: &Value) -> Result<(), String> { + let text = serde_json::to_string(value).map_err(|e| e.to_string())?; + ws.send(tungstenite::Message::Text(text)) + .map_err(|e| format!("send failed: {e}")) +} + +fn read_json(ws: &mut WsStream) -> Result { + loop { + match ws.read() { + Ok(tungstenite::Message::Text(text)) => { + return serde_json::from_str(&text) + .map_err(|e| format!("invalid JSON: {e}")); + } + Ok(tungstenite::Message::Ping(data)) => { + let _ = ws.send(tungstenite::Message::Pong(data)); + } + Ok(tungstenite::Message::Close(_)) => { + return Err("connection closed during handshake".into()); + } + Ok(_) => continue, + Err(e) => return Err(format!("read failed: {e}")), + } + } +} + +fn parse_entity_state(value: &Value) -> Option { + Some(EntityState { + entity_id: value.get("entity_id")?.as_str()?.to_owned(), + state: value.get("state")?.as_str()?.to_owned(), + attributes: value.get("attributes").cloned().unwrap_or(Value::Object(Default::default())), + last_changed: value + .get("last_changed") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(), + }) +} + +fn parse_statistics(value: &Value, entity_id: &str) -> Vec { + let result = match value.get("result") { + Some(r) => r, + None => return vec![], + }; + let stats = match result.get(entity_id).and_then(Value::as_array) { + Some(a) => a, + None => return vec![], + }; + stats + .iter() + .filter_map(|entry| { + let ts = entry.get("start")?.as_f64()?; + let val = entry.get("mean")?.as_f64()?; + Some(HistoryPoint { + timestamp: ts, + value: val, + }) + }) + .collect() +} + +/// Collect all entity IDs referenced in the HA config JSON. +pub fn entity_ids_from_config(config: &Value) -> Vec { + let mut ids = Vec::new(); + + if let Some(weather_id) = config + .get("weather") + .and_then(|w| w.get("entity_id")) + .and_then(Value::as_str) + { + ids.push(weather_id.to_owned()); + } + + if let Some(vacuum) = config.get("vacuum") { + if let Some(id) = vacuum.get("entity_id").and_then(Value::as_str) { + ids.push(id.to_owned()); + } + if let Some(id) = vacuum.get("cleaning_mode_entity").and_then(Value::as_str) { + ids.push(id.to_owned()); + } + } + + if let Some(rooms) = config.get("rooms").and_then(Value::as_array) { + for room in rooms { + if let Some(sensors) = room.get("sensors").and_then(Value::as_array) { + for sensor in sensors { + if let Some(id) = sensor.as_str() { + ids.push(id.to_owned()); + } + } + } + if let Some(cards) = room.get("cards").and_then(Value::as_array) { + for card in cards { + if let Some(id) = card.get("entity_id").and_then(Value::as_str) { + ids.push(id.to_owned()); + } + } + } + } + } + + ids.sort(); + ids.dedup(); + ids +} diff --git a/src/main.rs b/src/main.rs index 96ebac4..1d53e3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app_artwork; mod branding; mod diagnostics; mod font; +mod ha_client; mod i18n; #[cfg(all(feature = "webos", target_os = "linux", target_arch = "arm"))] mod luna; @@ -339,6 +340,52 @@ fn weather_tick(state: &mut State, service: &weather_service::Service, visible: } } +fn ha_tick(state: &mut State, service: &ha_client::HaService) { + while let Ok(response) = service.receiver.try_recv() { + match response { + ha_client::HaResponse::Connected => { + state.ha.connected = true; + state.dirty = true; + } + ha_client::HaResponse::Disconnected(_) => { + state.ha.connected = false; + state.dirty = true; + } + ha_client::HaResponse::AllStates(entities) => { + for entity in entities { + state.ha.entities.insert( + entity.entity_id, + state::HaEntity { + state: entity.state, + attributes: entity.attributes, + }, + ); + } + state.dirty = true; + } + ha_client::HaResponse::StateChanged(entity) => { + state.ha.entities.insert( + entity.entity_id, + state::HaEntity { + state: entity.state, + attributes: entity.attributes, + }, + ); + state.dirty = true; + } + ha_client::HaResponse::History { entity_id, points } => { + let data: Vec<(f64, f64)> = + points.into_iter().map(|p| (p.timestamp, p.value)).collect(); + state.ha.history.insert(entity_id, data); + state.dirty = true; + } + ha_client::HaResponse::ServiceResult { .. } => { + state.dirty = true; + } + } + } +} + fn scripted_click( sdl: &sdl2::Sdl, window: &sdl2::video::Window, @@ -470,6 +517,34 @@ fn run() -> Result<(), String> { weather_service::Client::new(options.weather_test_server.as_deref())?, weather_cache, ); + let ha_config_path = if webos { + PathBuf::from("/media/developer/apps/usr/palm/applications/com.ecklf.luma/ha-home/config.json") + } else { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ha-home/config.json") + }; + let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) { + if let Ok(config_value) = serde_json::from_slice::(&bytes) { + let url = config_value.get("ha").and_then(|h| h.get("url")).and_then(|v| v.as_str()).unwrap_or("").to_owned(); + let token = config_value.get("ha").and_then(|h| h.get("token")).and_then(|v| v.as_str()).unwrap_or("").to_owned(); + if !url.is_empty() && !token.is_empty() { + let entity_ids = ha_client::entity_ids_from_config(&config_value); + Some(ha_client::HaService::new(ha_client::HaConfig { + url, + token, + entity_ids, + })) + } else { + eprintln!("HA config: missing url or token, HA tab disabled"); + None + } + } else { + eprintln!("HA config: invalid JSON at {}", ha_config_path.display()); + None + } + } else { + eprintln!("HA config not found at {}, HA tab disabled", ha_config_path.display()); + None + }; let platform = Platform::new( webos, options.preferences.is_none() && options.test_extra_apps == 0, @@ -650,6 +725,9 @@ fn run() -> Result<(), String> { } } weather_tick(&mut state, &weather_service, visible); + if let Some(ref ha) = ha_service { + ha_tick(&mut state, ha); + } // Rebuild hit regions before input whenever inventory/layout changed. if state.dirty { scene = compose(&mut state, &assets, pill, pill_width, now); @@ -934,6 +1012,9 @@ fn run() -> Result<(), String> { } } weather_tick(&mut state, &weather_service, visible); + if let Some(ref ha) = ha_service { + ha_tick(&mut state, ha); + } // SDL-webOS disables text input when its own panel is dismissed, even // if it did not forward a Back/Return key to the application. if text_active diff --git a/src/state.rs b/src/state.rs index 3d5fd55..fbf08ee 100644 --- a/src/state.rs +++ b/src/state.rs @@ -147,6 +147,19 @@ pub const KEYS: [&str; 40] = [ "Delete", "Clear", "Done", ]; +#[derive(Default)] +pub struct HaState { + pub connected: bool, + pub entities: std::collections::HashMap, + pub history: std::collections::HashMap>, +} + +#[derive(Clone, Debug)] +pub struct HaEntity { + pub state: String, + pub attributes: serde_json::Value, +} + pub struct State { pub page: Page, pub focus: Target, @@ -172,6 +185,7 @@ pub struct State { pub previous_background: Option<(String, Instant)>, pub panel_started: Option, pub weather: weather::Weather, + pub ha: HaState, pub keyboard_field: TextField, pub greeting_name_draft: String, pub input_name: String, @@ -203,6 +217,7 @@ impl State { previous_background: None, panel_started: None, weather: weather::Weather::default(), + ha: HaState::default(), keyboard_field: TextField::Apps, greeting_name_draft: String::new(), input_name: String::new(),