Add WS-based pairing flow, replacing config.json with token file
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

Replace the static config.json with a dynamic pairing flow: the TV
shows a 6-digit PIN, the user enters it on the HA pairing web page,
and the TV polls for the provisioned token. On success the token is
persisted and the WS connection fetches entity config via luma/get_config.

New modules:
- ha_token: atomic read/write/remove of HA credentials file
- ha_pairing: background HTTP poll service with getrandom PIN generation

Key changes:
- Settings UI: new HA section with URL entry, connect/disconnect, PIN display
- ha_client: WS config fetch (luma/get_config), Option<u64> pending tracking
- main.rs: token-based startup, pairing effects consolidated into effects()
- renderer: cached GL uniform locations (u_aa, u_texture, u_clip_rounded,
  u_clip_radius, u_edge_fade) — no more per-batch GetUniformLocation calls
- view.rs: extracted card_glow() helper, slider_card_target() method
- Pairing thread detached (no join in Drop) to avoid blocking the render loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-25 14:38:10 +02:00
1 parent 483b19f286
commit ba4a5d2346
13 files changed
+928 -280

No files matched your search

+52 -38
View File
@@ -45,6 +45,7 @@ pub enum HaResponse {
id: u64,
success: bool,
},
Config(Value),
}
pub enum HaCommand {
@@ -59,6 +60,8 @@ pub enum HaCommand {
entity_id: String,
hours: u32,
},
RefreshStates,
FetchConfig,
}
// ── Config ────────────────────────────────────────────────────────────
@@ -115,13 +118,13 @@ impl Drop for HaService {
// ── Worker thread ─────────────────────────────────────────────────────
fn worker_loop(
config: HaConfig,
mut 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) {
match connect_and_run(&mut config, &responses, &commands, &closed) {
Ok(()) => break,
Err(e) => {
eprintln!("HA WebSocket: {e}");
@@ -136,7 +139,7 @@ fn worker_loop(
}
fn connect_and_run(
config: &HaConfig,
config: &mut HaConfig,
responses: &mpsc::Sender<HaResponse>,
commands: &mpsc::Receiver<HaCommand>,
closed: &Arc<AtomicBool>,
@@ -206,9 +209,18 @@ fn connect_and_run(
}),
)?;
// Pending service call / history requests mapped by HA message id
// Phase 5: fetch luma/get_config for dashboard layout
let config_id = next_id();
ws_send(
&mut ws,
&json!({ "id": config_id, "type": "luma/get_config" }),
)?;
// Pending service call / history / refresh 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 pending_refresh: Option<u64> = None;
let mut pending_config: Option<u64> = Some(config_id);
let mut last_ping = Instant::now();
@@ -242,6 +254,19 @@ fn connect_and_run(
}),
)?;
}
HaCommand::RefreshStates => {
let ha_id = next_id();
ws_send(&mut ws, &json!({ "id": ha_id, "type": "get_states" }))?;
pending_refresh = Some(ha_id);
}
HaCommand::FetchConfig => {
let ha_id = next_id();
ws_send(
&mut ws,
&json!({ "id": ha_id, "type": "luma/get_config" }),
)?;
pending_config = Some(ha_id);
}
HaCommand::FetchHistory { entity_id, hours } => {
let ha_id = next_id();
pending_history.insert(ha_id, entity_id.clone());
@@ -305,7 +330,8 @@ fn connect_and_run(
let id = value.get("id").and_then(Value::as_u64).unwrap_or(0);
match msg_type {
"result" if id == states_id => {
"result" if id == states_id || pending_refresh == Some(id) => {
pending_refresh = pending_refresh.filter(|&r| r != id);
if let Some(result) = value.get("result").and_then(Value::as_array) {
let states: Vec<EntityState> = result
.iter()
@@ -349,6 +375,21 @@ fn connect_and_run(
});
}
}
"result" if pending_config == Some(id) => {
pending_config = None;
if value.get("success").and_then(Value::as_bool) == Some(true) {
if let Some(result) = value.get("result") {
let new_ids = entity_ids_from_v2_config(result);
config.entity_ids = new_ids;
if !config.entity_ids.is_empty() {
let refresh_id = next_id();
ws_send(&mut ws, &json!({ "id": refresh_id, "type": "get_states" }))?;
pending_refresh = Some(refresh_id);
}
let _ = responses.send(HaResponse::Config(result.clone()));
}
}
}
"pong" => {}
_ => {}
}
@@ -419,51 +460,24 @@ fn parse_statistics(value: &Value, entity_id: &str) -> Vec<HistoryPoint> {
.collect()
}
/// Collect all entity IDs referenced in the HA config JSON.
pub fn entity_ids_from_config(config: &Value) -> Vec<String> {
/// Collect entity IDs from a v2 config (as returned by luma/get_config).
pub fn entity_ids_from_v2_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(media) = config.get("media").and_then(|m| m.get("entity_id")).and_then(Value::as_str) {
ids.push(media.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());
if !id.is_empty() {
ids.push(id.to_owned());
}
}
}
}
}
}
ids.sort();
ids.dedup();
ids
}