Add Home Assistant WebSocket client module
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s

- New ha_client.rs: background-thread WebSocket client using tungstenite
  with rustls TLS. Handles auth, state subscription, service calls, and
  recorder/statistics history fetch for climate graphs. Auto-reconnects
  on disconnect with 5s delay.
- HaState added to State struct: tracks connection status, entity states,
  and temperature history per entity.
- main.rs loads ha-home/config.json at startup, extracts all referenced
  entity IDs, and creates the HaService. ha_tick() drains responses into
  state each frame, matching the weather_tick pattern.
- tungstenite 0.24 added to Cargo.toml with handshake + rustls features.

All 84 existing tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 12:00:09 +03:00
1 parent cbea5a322f
commit 14db4f6e71
5 files changed
+856 -2

No files matched your search

Generated
+294 -2
View File
@@ -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"
+1
View File
@@ -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]
+465
View File
@@ -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<EntityState>),
History {
entity_id: String,
points: Vec<HistoryPoint>,
},
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<String>,
}
// ── Service (main-thread handle) ──────────────────────────────────────
pub struct HaService {
pub receiver: mpsc::Receiver<HaResponse>,
commands: mpsc::Sender<HaCommand>,
closed: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
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<HaResponse>,
commands: mpsc::Receiver<HaCommand>,
closed: Arc<AtomicBool>,
) {
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<HaResponse>,
commands: &mpsc::Receiver<HaCommand>,
closed: &Arc<AtomicBool>,
) -> 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<u64, u64> = HashMap::new(); // ha_id -> caller_id
let mut pending_history: HashMap<u64, String> = 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<EntityState> = 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<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>;
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<Value, String> {
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<EntityState> {
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<HistoryPoint> {
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<String> {
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
}
+81
View File
@@ -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::<serde_json::Value>(&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
+15
View File
@@ -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<String, HaEntity>,
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
}
#[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<Instant>,
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(),