Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e29050dc32 | ||
|
|
c8e6fcb659 |
No files matched your search
+3
-2
@@ -39,8 +39,9 @@
|
||||
{ "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": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }
|
||||
{ "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" }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -440,6 +440,10 @@ pub fn entity_ids_from_config(config: &Value) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+84
-27
@@ -327,6 +327,51 @@ fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
|
||||
}),
|
||||
});
|
||||
}
|
||||
HaEffect::MediaPlayPause(entity_id) => {
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "media_player".into(),
|
||||
service: "media_play_pause".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
HaEffect::MediaNext(entity_id) => {
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "media_player".into(),
|
||||
service: "media_next_track".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
HaEffect::MediaPrev(entity_id) => {
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "media_player".into(),
|
||||
service: "media_previous_track".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
data: serde_json::json!({}),
|
||||
});
|
||||
}
|
||||
HaEffect::SetVolume(entity_id, volume) => {
|
||||
service.send(ha_client::HaCommand::CallService {
|
||||
id: next_id(),
|
||||
domain: "media_player".into(),
|
||||
service: "volume_set".into(),
|
||||
target: serde_json::json!({ "entity_id": entity_id }),
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1027,34 +1072,46 @@ fn run() -> Result<(), String> {
|
||||
mouse_y,
|
||||
..
|
||||
} if state.overlay.is_none() => {
|
||||
let over_favorites = state.page == state::Page::Home
|
||||
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
|
||||
(64. ..1856.).contains(&x)
|
||||
&& scene.controls.iter().any(|control| {
|
||||
matches!(control.target, Target::App(_))
|
||||
&& control.rect.contains(x, y)
|
||||
})
|
||||
});
|
||||
if state.page == state::Page::Home && (x != 0 || over_favorites) {
|
||||
state.scroll_favorites(
|
||||
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
|
||||
);
|
||||
press = None;
|
||||
// HA card scroll: adjust range/climate/media values
|
||||
let ha_scroll = state.page == state::Page::Ha
|
||||
&& matches!(&state.focus, state::Target::HaCard(room, index)
|
||||
if matches!(
|
||||
state.ha.config.rooms[*room].cards[*index].card_type,
|
||||
state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media
|
||||
))
|
||||
&& y != 0;
|
||||
if ha_scroll {
|
||||
pending = state.scroll_ha(y);
|
||||
} else {
|
||||
let max = scene
|
||||
.controls
|
||||
.iter()
|
||||
.filter(|c| c.scrollable)
|
||||
.map(|c| {
|
||||
c.rect.y + c.rect.h + state.scroll
|
||||
- if state.page == state::Page::Home {
|
||||
1041.6
|
||||
} else {
|
||||
982.4
|
||||
}
|
||||
})
|
||||
.fold(0., f32::max);
|
||||
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
|
||||
let over_favorites = state.page == state::Page::Home
|
||||
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
|
||||
(64. ..1856.).contains(&x)
|
||||
&& scene.controls.iter().any(|control| {
|
||||
matches!(control.target, Target::App(_))
|
||||
&& control.rect.contains(x, y)
|
||||
})
|
||||
});
|
||||
if state.page == state::Page::Home && (x != 0 || over_favorites) {
|
||||
state.scroll_favorites(
|
||||
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
|
||||
);
|
||||
press = None;
|
||||
} else {
|
||||
let max = scene
|
||||
.controls
|
||||
.iter()
|
||||
.filter(|c| c.scrollable)
|
||||
.map(|c| {
|
||||
c.rect.y + c.rect.h + state.scroll
|
||||
- if state.page == state::Page::Home {
|
||||
1041.6
|
||||
} else {
|
||||
982.4
|
||||
}
|
||||
})
|
||||
.fold(0., f32::max);
|
||||
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
|
||||
}
|
||||
}
|
||||
state.pointer = true;
|
||||
state.dirty = true;
|
||||
|
||||
+67
-25
@@ -161,6 +161,11 @@ pub enum HaEffect {
|
||||
segments: Vec<u32>,
|
||||
mode: Option<String>,
|
||||
},
|
||||
MediaPlayPause(String),
|
||||
MediaNext(String),
|
||||
MediaPrev(String),
|
||||
SetVolume(String, f32),
|
||||
SetCoverPosition(String, u8),
|
||||
}
|
||||
|
||||
pub const KEYS: [&str; 40] = [
|
||||
@@ -176,6 +181,9 @@ pub enum HaCardType {
|
||||
Climate,
|
||||
Action,
|
||||
Vacuum,
|
||||
Media,
|
||||
Curtain,
|
||||
Blinds,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -284,6 +292,9 @@ impl HaTabConfig {
|
||||
Some("climate") => HaCardType::Climate,
|
||||
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();
|
||||
@@ -830,34 +841,15 @@ impl State {
|
||||
}
|
||||
if let Target::HaCard(room, index) = self.focus {
|
||||
let room_cfg = &self.ha.config.rooms[room];
|
||||
let card = &room_cfg.cards[index];
|
||||
let cols = self.ha_card_columns();
|
||||
match (&card.card_type, direction) {
|
||||
(HaCardType::Range, Direction::Left) => {
|
||||
let brightness = self.ha_entity_brightness(&card.entity_id);
|
||||
let new = (brightness - 25.5).max(0.);
|
||||
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
|
||||
}
|
||||
(HaCardType::Range, Direction::Right) => {
|
||||
let brightness = self.ha_entity_brightness(&card.entity_id);
|
||||
let new = (brightness + 25.5).min(255.);
|
||||
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
|
||||
}
|
||||
(HaCardType::Climate, Direction::Left) => {
|
||||
let temp = self.ha_entity_target_temp(&card.entity_id);
|
||||
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp - 0.5))];
|
||||
}
|
||||
(HaCardType::Climate, Direction::Right) => {
|
||||
let temp = self.ha_entity_target_temp(&card.entity_id);
|
||||
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp + 0.5))];
|
||||
}
|
||||
(_, Direction::Left) if index % cols > 0 => {
|
||||
match direction {
|
||||
Direction::Left if index % cols > 0 => {
|
||||
self.focus(Target::HaCard(room, index - 1), controls);
|
||||
}
|
||||
(_, Direction::Right) if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
|
||||
Direction::Right if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
|
||||
self.focus(Target::HaCard(room, index + 1), controls);
|
||||
}
|
||||
(_, Direction::Up) => {
|
||||
Direction::Up => {
|
||||
if index >= cols {
|
||||
self.focus(Target::HaCard(room, index - cols), controls);
|
||||
} else if self.ha.config.weather.is_some() {
|
||||
@@ -866,7 +858,7 @@ impl State {
|
||||
self.focus(Target::HaRoom(self.ha.room), controls);
|
||||
}
|
||||
}
|
||||
(_, Direction::Down) => {
|
||||
Direction::Down => {
|
||||
let next = index + cols;
|
||||
if next < room_cfg.cards.len() {
|
||||
self.focus(Target::HaCard(room, next), controls);
|
||||
@@ -1225,7 +1217,7 @@ impl State {
|
||||
}
|
||||
|
||||
fn ha_card_columns(&self) -> usize {
|
||||
3
|
||||
4
|
||||
}
|
||||
|
||||
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
|
||||
@@ -1242,6 +1234,50 @@ impl State {
|
||||
.unwrap_or(20.) as f32
|
||||
}
|
||||
|
||||
fn ha_entity_volume(&self, entity_id: &str) -> f32 {
|
||||
self.ha.entities.get(entity_id)
|
||||
.and_then(|e| e.attributes.get("volume_level"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.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> {
|
||||
if let Target::HaCard(room, index) = &self.focus {
|
||||
let card = &self.ha.config.rooms[*room].cards[*index];
|
||||
match card.card_type {
|
||||
HaCardType::Range => {
|
||||
let brightness = self.ha_entity_brightness(&card.entity_id);
|
||||
let new = (brightness + delta as f32 * 25.5).clamp(0., 255.);
|
||||
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
|
||||
}
|
||||
HaCardType::Climate => {
|
||||
let temp = self.ha_entity_target_temp(&card.entity_id);
|
||||
let new = temp + delta as f32 * 0.5;
|
||||
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), new))];
|
||||
}
|
||||
HaCardType::Media => {
|
||||
let vol = self.ha_entity_volume(&card.entity_id);
|
||||
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))];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
|
||||
let vac = match &self.ha.config.vacuum {
|
||||
Some(v) => v,
|
||||
@@ -1670,6 +1706,12 @@ impl State {
|
||||
self.focus = Target::HaVacuumRoom(0);
|
||||
}
|
||||
}
|
||||
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()))];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+478
-83
@@ -1677,10 +1677,10 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
|
||||
}
|
||||
|
||||
// ── Card grid ────────────────────────────────────────────────
|
||||
let cols = 3usize;
|
||||
let card_w = 564.;
|
||||
let cols = 4usize;
|
||||
let card_gap = 16.;
|
||||
let card_h = 88.;
|
||||
let card_w = (1752. - (cols - 1) as f32 * card_gap) / cols as f32;
|
||||
let card_h = 160.;
|
||||
|
||||
for (index, card) in room.cards.iter().enumerate() {
|
||||
let col = index % cols;
|
||||
@@ -1707,6 +1707,15 @@ fn ha(out: &mut Scene, state: &State, assets: &Assets) {
|
||||
HaCardType::Vacuum => {
|
||||
ha_vacuum_card(out, state, assets, rect, card, entity, focused);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1752,6 +1761,9 @@ fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'st
|
||||
HaCardType::Climate => "thermometer",
|
||||
HaCardType::Action => "scene",
|
||||
HaCardType::Vacuum => "vacuum",
|
||||
HaCardType::Media => "spotify",
|
||||
HaCardType::Curtain => "curtain",
|
||||
HaCardType::Blinds => "curtain",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1760,10 +1772,66 @@ fn ha_state_color(entity: Option<&crate::state::HaEntity>) -> Color {
|
||||
Some("on") => c(0x7EB8DA, 1.),
|
||||
Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.),
|
||||
Some("cool") => c(0x60b0f0, 1.),
|
||||
Some("playing") => c(0x9b7ddb, 1.),
|
||||
Some("paused") => c(0x7d9bdb, 0.7),
|
||||
_ => c(0xbbbbbb, 0.5),
|
||||
}
|
||||
}
|
||||
|
||||
fn ha_fill_color(card_type: &crate::state::HaCardType, entity: Option<&crate::state::HaEntity>) -> Color {
|
||||
use crate::state::HaCardType;
|
||||
match card_type {
|
||||
HaCardType::Range => c(0xfab95c, 0.30),
|
||||
HaCardType::Climate => {
|
||||
match entity.map(|e| e.state.as_str()) {
|
||||
Some("heat" | "heat_cool" | "auto") => c(0xf06040, 0.28),
|
||||
Some("cool") => c(0x4090f0, 0.28),
|
||||
_ => c(0xf0a060, 0.15),
|
||||
}
|
||||
}
|
||||
HaCardType::Toggle => {
|
||||
let is_on = entity.is_some_and(|e| e.state == "on" || e.state == "open");
|
||||
if is_on {
|
||||
if entity.is_some_and(|e| e.attributes.get("device_class").and_then(|v| v.as_str()) == Some("lock")) {
|
||||
c(0x50b860, 0.28)
|
||||
} else {
|
||||
c(0xfab95c, 0.25)
|
||||
}
|
||||
} else {
|
||||
c(0, 0.)
|
||||
}
|
||||
}
|
||||
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),
|
||||
Some("returning") => c(0xf0a060, 0.20),
|
||||
_ => c(0, 0.),
|
||||
}
|
||||
}
|
||||
_ => c(0, 0.),
|
||||
}
|
||||
}
|
||||
|
||||
fn ha_card_fill(out: &mut Scene, rect: Rect, fill_pct: f32, color: Color) {
|
||||
if fill_pct <= 0.01 || color[3] < 0.01 { return; }
|
||||
let pct = fill_pct.clamp(0., 1.);
|
||||
let fill_w = (rect.w * pct).max(44.);
|
||||
let radius = 22f32.min(fill_w / 2.);
|
||||
out.shape(Rect::new(rect.x, rect.y, fill_w, rect.h), radius, color);
|
||||
}
|
||||
|
||||
// ── Toggle card ──────────────────────────────────────────────────
|
||||
|
||||
fn ha_toggle_card(
|
||||
@@ -1778,37 +1846,67 @@ fn ha_toggle_card(
|
||||
ha_glass(out, rect, focused);
|
||||
let icon_name = ha_entity_icon(&card.card_type, &card.entity_id);
|
||||
let accent = ha_state_color(entity);
|
||||
let is_on = entity.is_some_and(|e| e.state == "on");
|
||||
let is_on = entity.is_some_and(|e| e.state == "on" || e.state == "open");
|
||||
|
||||
let fill_color = ha_fill_color(&card.card_type, entity);
|
||||
if is_on {
|
||||
ha_card_fill(out, rect, 1., fill_color);
|
||||
}
|
||||
|
||||
// Icon in tinted circle
|
||||
let icon_bg = if is_on { [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,
|
||||
icon_name,
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||
if is_on { accent } else { c(0xbbbbbb, 0.5) },
|
||||
);
|
||||
|
||||
// Type label (small)
|
||||
let type_label = if card.entity_id.starts_with("light.") {
|
||||
"Light"
|
||||
} else if card.entity_id.starts_with("cover.") {
|
||||
"Cover"
|
||||
} else if card.entity_id.starts_with("switch.") {
|
||||
"Switch"
|
||||
} else {
|
||||
"Toggle"
|
||||
};
|
||||
out.text(
|
||||
type_label,
|
||||
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
|
||||
text(12., 400, 0.04),
|
||||
if is_on { accent } else { c(0xe8e8e8, 0.45) },
|
||||
false,
|
||||
);
|
||||
// Entity name
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.),
|
||||
text(20., 500, -0.02),
|
||||
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||
text(18., 600, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
let status = entity.map_or("unavailable", |e| e.state.as_str());
|
||||
|
||||
// Status at bottom left
|
||||
let status_text = if is_on { "On" } else { entity.map_or("Unavailable", |e| e.state.as_str()) };
|
||||
let status_text = match status_text {
|
||||
"off" => "Off",
|
||||
"on" => "On",
|
||||
"open" => "Open",
|
||||
"closed" => "Closed",
|
||||
"locked" => "Locked",
|
||||
"unlocked" => "Unlocked",
|
||||
other => other,
|
||||
};
|
||||
out.text(
|
||||
status,
|
||||
Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.),
|
||||
text(13.12, 400, 0.),
|
||||
c(0xe8e8e8, 0.48),
|
||||
status_text,
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
|
||||
text(22., 700, -0.02),
|
||||
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
);
|
||||
// On/off indicator dot
|
||||
let dot_x = rect.x + rect.w - 36.;
|
||||
let dot_y = rect.y + rect.h / 2. - 8.;
|
||||
out.shape(
|
||||
Rect::new(dot_x, dot_y, 16., 16.),
|
||||
8.,
|
||||
if is_on { accent } else { c(0x555555, 0.6) },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Range card (dimmable lights, cover position) ─────────────────
|
||||
@@ -1829,44 +1927,60 @@ fn ha_range_card(
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
let pct = (brightness / 255. * 100.).round() as u8;
|
||||
let accent = if is_on { c(0x7EB8DA, 1.) } else { c(0xbbbbbb, 0.5) };
|
||||
let accent = if is_on { c(0xfab95c, 1.) } else { c(0xbbbbbb, 0.5) };
|
||||
|
||||
// Fill background proportional to brightness
|
||||
if is_on && pct > 0 {
|
||||
let fill_color = ha_fill_color(&card.card_type, entity);
|
||||
ha_card_fill(out, rect, pct as f32 / 100., fill_color);
|
||||
}
|
||||
|
||||
// Icon in tinted circle
|
||||
let icon_bg = if is_on { [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,
|
||||
"light",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||
accent,
|
||||
);
|
||||
|
||||
// Type label
|
||||
out.text(
|
||||
"Light",
|
||||
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
|
||||
text(12., 400, 0.04),
|
||||
if is_on { accent } else { c(0xe8e8e8, 0.45) },
|
||||
false,
|
||||
);
|
||||
// Entity name
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.),
|
||||
text(20., 500, -0.02),
|
||||
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||
text(18., 600, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
// Brightness bar
|
||||
let bar_x = rect.x + 80.;
|
||||
let bar_y = rect.y + 52.;
|
||||
let bar_w = rect.w - 160.;
|
||||
let bar_h = 8.;
|
||||
out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 4., c(0xffffff, 0.08));
|
||||
if is_on && pct > 0 {
|
||||
let fill_w = bar_w * (pct as f32 / 100.).min(1.);
|
||||
out.shape(Rect::new(bar_x, bar_y, fill_w, bar_h), 4., accent);
|
||||
}
|
||||
// Percentage label
|
||||
|
||||
// Value at bottom left
|
||||
let label = if is_on { format!("{}%", pct) } else { "Off".into() };
|
||||
out.text(
|
||||
&label,
|
||||
Rect::new(rect.x + rect.w - 72., rect.y + 34., 52., 20.),
|
||||
text(15.2, 500, 0.).right(),
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
|
||||
text(22., 700, -0.02),
|
||||
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
);
|
||||
if focused {
|
||||
// D-pad hint arrows
|
||||
out.icon(assets, "left", Rect::new(rect.x + 80., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
|
||||
out.icon(assets, "right", Rect::new(rect.x + 96., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
|
||||
|
||||
// Scroll hint when focused
|
||||
if focused && is_on {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1892,53 +2006,89 @@ fn ha_climate_card(
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.);
|
||||
let hvac_mode = entity.map_or("off", |e| e.state.as_str());
|
||||
let is_active = hvac_mode != "off" && hvac_mode != "unavailable";
|
||||
|
||||
// Fill proportional to target temp (15-35°C range)
|
||||
if is_active {
|
||||
let fill_pct = ((target_temp - 15.) / 20.).clamp(0., 1.) as f32;
|
||||
let fill_color = ha_fill_color(&card.card_type, entity);
|
||||
ha_card_fill(out, rect, fill_pct, fill_color);
|
||||
}
|
||||
|
||||
// Icon in tinted circle
|
||||
let icon_bg = if is_active { [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,
|
||||
"thermometer",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||
accent,
|
||||
);
|
||||
|
||||
// Mode label
|
||||
let mode_label = match hvac_mode {
|
||||
"heat" => "Heat",
|
||||
"cool" => "Cool",
|
||||
"heat_cool" | "auto" => "Auto",
|
||||
"off" => "Off",
|
||||
other => other,
|
||||
};
|
||||
out.text(
|
||||
mode_label,
|
||||
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
|
||||
text(12., 400, 0.04),
|
||||
if is_active { accent } else { c(0xe8e8e8, 0.45) },
|
||||
false,
|
||||
);
|
||||
// Entity name
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 16., 280., 24.),
|
||||
text(20., 500, -0.02),
|
||||
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||
text(18., 600, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
|
||||
// Current temp small label
|
||||
let now_str = format!("Now {:.0}°C", current_temp);
|
||||
out.text(
|
||||
hvac_mode,
|
||||
Rect::new(rect.x + 80., rect.y + 46., 120., 16.),
|
||||
text(13.12, 400, 0.),
|
||||
c(0xe8e8e8, 0.48),
|
||||
&now_str,
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 60., rect.w - 32., 16.),
|
||||
text(12., 400, 0.02),
|
||||
c(0xe8e8e8, 0.55),
|
||||
false,
|
||||
);
|
||||
// Current temperature
|
||||
let current_str = format!("{:.1}°", current_temp);
|
||||
|
||||
// Target/current temperature large
|
||||
let temp_str = format!("{:.0}°", target_temp);
|
||||
out.text(
|
||||
¤t_str,
|
||||
Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.),
|
||||
text(24., 600, -0.03).right(),
|
||||
WHITE,
|
||||
&temp_str,
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 42., rect.w - 32., 30.),
|
||||
text(26., 700, -0.02),
|
||||
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
);
|
||||
// Target temperature
|
||||
let target_str = format!("→ {:.1}°", target_temp);
|
||||
out.text(
|
||||
&target_str,
|
||||
Rect::new(rect.x + rect.w - 108., rect.y + 18., 88., 24.),
|
||||
text(18., 400, 0.),
|
||||
accent,
|
||||
false,
|
||||
);
|
||||
if focused {
|
||||
out.icon(assets, "left", Rect::new(rect.x + rect.w - 108., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
|
||||
out.icon(assets, "right", Rect::new(rect.x + rect.w - 92., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
|
||||
|
||||
// Scroll hint when focused
|
||||
if focused && is_active {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// History sparkline if available
|
||||
if let Some(points) = state.ha.history.get(&card.entity_id) {
|
||||
if points.len() >= 2 {
|
||||
ha_sparkline(out, Rect::new(rect.x + 80., rect.y + 64., 300., 16.), points, accent);
|
||||
ha_sparkline(
|
||||
out,
|
||||
Rect::new(rect.x + 76., rect.y + 64., rect.w - 92., 20.),
|
||||
points,
|
||||
[accent[0], accent[1], accent[2], 0.5],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1974,24 +2124,41 @@ fn ha_action_card(
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let accent = c(0xc9a0dc, 1.);
|
||||
|
||||
// Icon in tinted circle
|
||||
let icon_bg = [accent[0], accent[1], accent[2], 0.15];
|
||||
out.shape(Rect::new(rect.x + 16., rect.y + 16., 48., 48.), 24., icon_bg);
|
||||
out.icon(
|
||||
assets,
|
||||
"scene",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||
accent,
|
||||
);
|
||||
|
||||
// Type label
|
||||
let type_label = if card.entity_id.starts_with("script.") { "Script" } else { "Scene" };
|
||||
out.text(
|
||||
type_label,
|
||||
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
|
||||
text(12., 400, 0.04),
|
||||
[accent[0], accent[1], accent[2], 0.7],
|
||||
false,
|
||||
);
|
||||
// Entity name
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.),
|
||||
text(20., 500, -0.02),
|
||||
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||
text(18., 600, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
|
||||
// Action hint
|
||||
if focused {
|
||||
out.text(
|
||||
"OK to activate",
|
||||
Rect::new(rect.x + 80., rect.y + 60., 200., 16.),
|
||||
text(12., 400, 0.),
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - 32., 16.),
|
||||
text(12., 400, 0.02),
|
||||
c(0xe8e8e8, 0.40),
|
||||
false,
|
||||
);
|
||||
@@ -2016,32 +2183,260 @@ fn ha_vacuum_card(
|
||||
"returning" => c(0xf0a060, 1.),
|
||||
_ => c(0xbbbbbb, 0.5),
|
||||
};
|
||||
|
||||
// Fill for active states
|
||||
let fill_color = ha_fill_color(&card.card_type, entity);
|
||||
if status == "cleaning" || status == "returning" {
|
||||
ha_card_fill(out, rect, if status == "cleaning" { 0.6 } else { 0.3 }, fill_color);
|
||||
}
|
||||
|
||||
// Icon in tinted circle
|
||||
let icon_bg = if status == "cleaning" || status == "returning" {
|
||||
[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,
|
||||
"vacuum",
|
||||
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
|
||||
Rect::new(rect.x + 24., rect.y + 24., 32., 32.),
|
||||
accent,
|
||||
);
|
||||
|
||||
// Type label
|
||||
out.text(
|
||||
"Vacuum",
|
||||
Rect::new(rect.x + 76., rect.y + 18., rect.w - 92., 14.),
|
||||
text(12., 400, 0.04),
|
||||
if status == "cleaning" { accent } else { c(0xe8e8e8, 0.45) },
|
||||
false,
|
||||
);
|
||||
// Entity name
|
||||
out.text(
|
||||
&card.name,
|
||||
Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.),
|
||||
text(20., 500, -0.02),
|
||||
Rect::new(rect.x + 76., rect.y + 36., rect.w - 92., 24.),
|
||||
text(18., 600, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
|
||||
// Status at bottom
|
||||
let display_status = match status {
|
||||
"cleaning" => "Cleaning",
|
||||
"returning" => "Returning",
|
||||
"docked" => "Docked",
|
||||
"idle" => "Idle",
|
||||
"paused" => "Paused",
|
||||
other => other,
|
||||
};
|
||||
out.text(
|
||||
status,
|
||||
Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.),
|
||||
text(13.12, 400, 0.),
|
||||
c(0xe8e8e8, 0.48),
|
||||
display_status,
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 38., rect.w - 32., 26.),
|
||||
text(22., 700, -0.02),
|
||||
if status == "cleaning" || status == "returning" { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Media card (Music Assistant / media_player) ────────────────
|
||||
|
||||
fn ha_media_card(
|
||||
out: &mut Scene,
|
||||
_state: &State,
|
||||
assets: &Assets,
|
||||
rect: Rect,
|
||||
card: &crate::state::HaCardConfig,
|
||||
entity: Option<&crate::state::HaEntity>,
|
||||
focused: bool,
|
||||
) {
|
||||
ha_glass(out, rect, focused);
|
||||
let accent = ha_state_color(entity);
|
||||
let is_playing = entity.is_some_and(|e| e.state == "playing");
|
||||
let is_active = entity.is_some_and(|e| e.state == "playing" || e.state == "paused");
|
||||
|
||||
// Volume fill background
|
||||
let volume = entity
|
||||
.and_then(|e| e.attributes.get("volume_level"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.) as f32;
|
||||
if is_active && volume > 0. {
|
||||
let fill_color = ha_fill_color(&card.card_type, entity);
|
||||
ha_card_fill(out, rect, volume, fill_color);
|
||||
}
|
||||
|
||||
// Album art circle (right side)
|
||||
let art_size = 96.;
|
||||
let art_x = rect.x + rect.w - art_size - 20.;
|
||||
let art_y = rect.y + (rect.h - art_size) / 2.;
|
||||
out.shape(
|
||||
Rect::new(art_x, art_y, art_size, art_size),
|
||||
art_size / 2.,
|
||||
if is_active { [accent[0], accent[1], accent[2], 0.25] } else { c(0xffffff, 0.08) },
|
||||
);
|
||||
out.icon(
|
||||
assets,
|
||||
"spotify",
|
||||
Rect::new(art_x + 24., art_y + 24., 48., 48.),
|
||||
if is_active { accent } else { c(0xbbbbbb, 0.4) },
|
||||
);
|
||||
|
||||
// Media source / state label
|
||||
let media_source = entity
|
||||
.and_then(|e| e.attributes.get("media_content_type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Media");
|
||||
let state_str = entity.map_or("off", |e| e.state.as_str());
|
||||
let status_label = match state_str {
|
||||
"playing" => format!("{} · Playing", media_source),
|
||||
"paused" => format!("{} · Paused", media_source),
|
||||
"idle" => "Idle".into(),
|
||||
_ => "Off".into(),
|
||||
};
|
||||
out.text(
|
||||
&status_label,
|
||||
Rect::new(rect.x + 16., rect.y + 16., rect.w - art_size - 56., 14.),
|
||||
text(12., 400, 0.03),
|
||||
if is_active { accent } else { c(0xe8e8e8, 0.45) },
|
||||
false,
|
||||
);
|
||||
|
||||
// Song title
|
||||
let title = entity
|
||||
.and_then(|e| e.attributes.get("media_title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&card.name);
|
||||
out.text(
|
||||
title,
|
||||
Rect::new(rect.x + 16., rect.y + 36., rect.w - art_size - 56., 24.),
|
||||
text(18., 600, -0.02),
|
||||
WHITE,
|
||||
false,
|
||||
);
|
||||
|
||||
// Artist
|
||||
let artist = entity
|
||||
.and_then(|e| e.attributes.get("media_artist"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !artist.is_empty() {
|
||||
out.text(
|
||||
artist,
|
||||
Rect::new(rect.x + 16., rect.y + 64., rect.w - art_size - 56., 18.),
|
||||
text(14., 400, 0.),
|
||||
c(0xe8e8e8, 0.6),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// Volume indicator at bottom
|
||||
let vol_pct = (volume * 100.).round() as u8;
|
||||
let vol_label = if is_active { format!("Vol {}%", vol_pct) } else { "Off".into() };
|
||||
out.text(
|
||||
&vol_label,
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 32., rect.w - art_size - 56., 18.),
|
||||
text(14., 500, 0.),
|
||||
if is_active { WHITE } else { c(0xbbbbbb, 0.5) },
|
||||
false,
|
||||
);
|
||||
|
||||
// Scroll + OK hint when focused
|
||||
if focused && is_active {
|
||||
out.text(
|
||||
"OK ▶/❚❚ scroll vol",
|
||||
Rect::new(rect.x + 16., rect.y + rect.h - 16., rect.w - art_size - 56., 12.),
|
||||
text(10., 400, 0.02),
|
||||
c(0xeeeeee, 0.30),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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(
|
||||
"OK to open",
|
||||
Rect::new(rect.x + rect.w - 120., rect.y + 36., 100., 16.),
|
||||
text(12., 400, 0.).right(),
|
||||
c(0xe8e8e8, 0.40),
|
||||
"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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user