From e29050dc3234b05e1addd62bf75751dda8adc7cd Mon Sep 17 00:00:00 2001 From: Kaloyan Danchev Date: Sat, 24 Oct 2026 14:46:09 +0300 Subject: [PATCH] Add curtain/blinds cover cards with daylight glow and navy darkening New HaCardType::Curtain and HaCardType::Blinds with: - Scroll to adjust position (10% steps via cover.set_cover_position) - OK to toggle open/close - Dark navy overlay when closed, warm daylight glow when open - Curtain glow radiates from center, blinds from top Co-Authored-By: Claude Opus 4.6 --- ha-home/config.json | 2 +- src/main.rs | 9 ++++ src/state.rs | 20 ++++++++ src/view.rs | 109 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/ha-home/config.json b/ha-home/config.json index 1e17c17..d2f8d37 100644 --- a/ha-home/config.json +++ b/ha-home/config.json @@ -39,7 +39,7 @@ { "type": "range", "entity_id": "light.rgbic_strip_lights_z1", "name": "LED Strip 1" }, { "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" }, { "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" }, - { "type": "toggle", "entity_id": "cover.living_room_curtain", "name": "Curtain" }, + { "type": "curtain", "entity_id": "cover.living_room_curtain", "name": "Curtain" }, { "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }, { "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" } ] diff --git a/src/main.rs b/src/main.rs index 56bbad4..e89da46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -363,6 +363,15 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) { data: serde_json::json!({ "volume_level": volume }), }); } + HaEffect::SetCoverPosition(entity_id, position) => { + service.send(ha_client::HaCommand::CallService { + id: next_id(), + domain: "cover".into(), + service: "set_cover_position".into(), + target: serde_json::json!({ "entity_id": entity_id }), + data: serde_json::json!({ "position": position }), + }); + } } } diff --git a/src/state.rs b/src/state.rs index 5419b9e..f8db35e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -165,6 +165,7 @@ pub enum HaEffect { MediaNext(String), MediaPrev(String), SetVolume(String, f32), + SetCoverPosition(String, u8), } pub const KEYS: [&str; 40] = [ @@ -181,6 +182,8 @@ pub enum HaCardType { Action, Vacuum, Media, + Curtain, + Blinds, } #[derive(Clone, Debug)] @@ -290,6 +293,8 @@ impl HaTabConfig { Some("action") => HaCardType::Action, Some("vacuum") => HaCardType::Vacuum, Some("media") => HaCardType::Media, + Some("curtain") => HaCardType::Curtain, + Some("blinds") => HaCardType::Blinds, _ => continue, }; let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned(); @@ -1236,6 +1241,13 @@ impl State { .unwrap_or(0.) as f32 } + fn ha_entity_position(&self, entity_id: &str) -> f32 { + self.ha.entities.get(entity_id) + .and_then(|e| e.attributes.get("current_position")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.) as f32 + } + pub fn scroll_ha(&mut self, delta: i32) -> Vec { if let Target::HaCard(room, index) = &self.focus { let card = &self.ha.config.rooms[*room].cards[*index]; @@ -1255,6 +1267,11 @@ impl State { let new = (vol + delta as f32 * 0.05).clamp(0., 1.); return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))]; } + HaCardType::Curtain | HaCardType::Blinds => { + let pos = self.ha_entity_position(&card.entity_id); + let new = (pos + delta as f32 * 10.).clamp(0., 100.) as u8; + return vec![Effect::Ha(HaEffect::SetCoverPosition(card.entity_id.clone(), new))]; + } _ => {} } } @@ -1692,6 +1709,9 @@ impl State { HaCardType::Media => { return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; } + HaCardType::Curtain | HaCardType::Blinds => { + return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))]; + } _ => {} } } diff --git a/src/view.rs b/src/view.rs index 4f20a49..679c968 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1710,6 +1710,12 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) { HaCardType::Media => { ha_media_card(out, state, assets, rect, card, entity, focused); } + HaCardType::Curtain => { + ha_cover_card(out, state, assets, rect, card, entity, focused, true); + } + HaCardType::Blinds => { + ha_cover_card(out, state, assets, rect, card, entity, focused, false); + } } out.control(Target::HaCard(state.ha.room, index), rect, true); } @@ -1756,6 +1762,8 @@ fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'st HaCardType::Action => "scene", HaCardType::Vacuum => "vacuum", HaCardType::Media => "spotify", + HaCardType::Curtain => "curtain", + HaCardType::Blinds => "curtain", } } @@ -1794,6 +1802,17 @@ fn ha_fill_color(card_type: &crate::state::HaCardType, entity: Option<&crate::st } } HaCardType::Media => c(0x8060c0, 0.25), + HaCardType::Curtain | HaCardType::Blinds => { + let pos = entity + .and_then(|e| e.attributes.get("current_position")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.); + if pos > 5. { + c(0xdcd2b4, (0.15 + pos as f32 / 100. * 0.25).min(0.40)) + } else { + c(0x0c1024, 0.50) + } + } HaCardType::Vacuum => { match entity.map(|e| e.state.as_str()) { Some("cleaning") => c(0x7EB8DA, 0.25), @@ -2333,6 +2352,96 @@ fn ha_media_card( } } +// ── Cover card (curtain / blinds) ──────────────────────────────── + +fn ha_cover_card( + out: &mut Scene, + _state: &State, + assets: &Assets, + rect: Rect, + card: &crate::state::HaCardConfig, + entity: Option<&crate::state::HaEntity>, + focused: bool, + is_curtain: bool, +) { + ha_glass(out, rect, focused); + let position = entity + .and_then(|e| e.attributes.get("current_position")) + .and_then(|v| v.as_f64()) + .unwrap_or(0.) as f32; + let is_open = position > 5.; + + // Dark navy overlay when closed (proportional to how closed it is) + let closed_pct = 1. - (position / 100.).clamp(0., 1.); + if closed_pct > 0.05 { + out.shape(rect, 22., c(0x0c1024, 0.5 * closed_pct)); + } + + // Daylight glow when open + if is_open { + let open_pct = (position / 100.).clamp(0., 1.); + let fill_color = ha_fill_color(&card.card_type, entity); + if is_curtain { + // Curtain: glow from center + ha_card_fill(out, rect, open_pct, fill_color); + } else { + // Blinds: glow from top + ha_card_fill(out, rect, open_pct, fill_color); + } + } + + let accent = if is_open { c(0xdcd2b4, 1.) } else { c(0xbbbbbb, 0.5) }; + + // Icon + let icon_bg = if is_open { [accent[0], accent[1], accent[2], 0.2] } else { c(0xffffff, 0.08) }; + out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg); + out.icon( + assets, + ha_entity_icon(&card.card_type, &card.entity_id), + Rect::new(rect.x + 24., rect.y + 24., 32., 32.), + accent, + ); + + // Type label + let type_label = if is_curtain { "Curtain" } else { "Blinds" }; + out.text( + type_label, + Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.), + text(12., 400, 0.04), + if is_open { accent } else { c(0xe8e8e8, 0.45) }, + false, + ); + // Entity name + out.text( + &card.name, + Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.), + text(18., 600, -0.02), + WHITE, + false, + ); + + // Position value at bottom + let label = if position > 0. { format!("{}%", position.round() as u8) } else { "Closed".into() }; + out.text( + &label, + Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.), + text(22., 700, -0.02), + if is_open { WHITE } else { c(0xbbbbbb, 0.5) }, + false, + ); + + // Scroll hint + if focused { + out.text( + "scroll", + Rect::new(rect.x + rect.w - 68., rect.y + rect.h - 28., 52., 14.), + text(11., 400, 0.03).right(), + c(0xeeeeee, 0.35), + false, + ); + } +} + // ── Weather hero card ──────────────────────────────────────────── fn ha_weather_card(