Add curtain/blinds cover cards with daylight glow and navy darkening
Release PR / Maintain Version Packages PR (push) Canceled after 0s
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

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 <noreply@anthropic.com>
This commit is contained in:
Kaloyan DanchevandClaude Opus 4.6 committed 2026-10-24 14:46:09 +03:00
1 parent c8e6fcb659
commit e29050dc32
4 files changed
+139 -1

No files matched your search

+1 -1
View File
@@ -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_z1", "name": "LED Strip 1" },
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" }, { "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": "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": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" },
{ "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" } { "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" }
] ]
+9
View File
@@ -363,6 +363,15 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
data: serde_json::json!({ "volume_level": volume }), 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 }),
});
}
} }
} }
+20
View File
@@ -165,6 +165,7 @@ pub enum HaEffect {
MediaNext(String), MediaNext(String),
MediaPrev(String), MediaPrev(String),
SetVolume(String, f32), SetVolume(String, f32),
SetCoverPosition(String, u8),
} }
pub const KEYS: [&str; 40] = [ pub const KEYS: [&str; 40] = [
@@ -181,6 +182,8 @@ pub enum HaCardType {
Action, Action,
Vacuum, Vacuum,
Media, Media,
Curtain,
Blinds,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -290,6 +293,8 @@ impl HaTabConfig {
Some("action") => HaCardType::Action, Some("action") => HaCardType::Action,
Some("vacuum") => HaCardType::Vacuum, Some("vacuum") => HaCardType::Vacuum,
Some("media") => HaCardType::Media, Some("media") => HaCardType::Media,
Some("curtain") => HaCardType::Curtain,
Some("blinds") => HaCardType::Blinds,
_ => continue, _ => continue,
}; };
let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned(); 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 .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<Effect> { pub fn scroll_ha(&mut self, delta: i32) -> Vec<Effect> {
if let Target::HaCard(room, index) = &self.focus { if let Target::HaCard(room, index) = &self.focus {
let card = &self.ha.config.rooms[*room].cards[*index]; 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.); let new = (vol + delta as f32 * 0.05).clamp(0., 1.);
return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))]; 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 => { HaCardType::Media => {
return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))]; return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))];
} }
HaCardType::Curtain | HaCardType::Blinds => {
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
}
_ => {} _ => {}
} }
} }
+109
View File
@@ -1710,6 +1710,12 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
HaCardType::Media => { HaCardType::Media => {
ha_media_card(out, state, assets, rect, card, entity, focused); 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); 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::Action => "scene",
HaCardType::Vacuum => "vacuum", HaCardType::Vacuum => "vacuum",
HaCardType::Media => "spotify", 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::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 => { HaCardType::Vacuum => {
match entity.map(|e| e.state.as_str()) { match entity.map(|e| e.state.as_str()) {
Some("cleaning") => c(0x7EB8DA, 0.25), 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 ──────────────────────────────────────────── // ── Weather hero card ────────────────────────────────────────────
fn ha_weather_card( fn ha_weather_card(