Skip to content

Commit a33da52

Browse files
committed
autosave (vm / Linux)
1 parent c0346c7 commit a33da52

File tree

8 files changed

+88
-10
lines changed

8 files changed

+88
-10
lines changed

modules/nixos/zwave.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ let
3030
sendEvents = true;
3131
publishNodeDetails = true;
3232
entityTemplate = "%nid";
33+
ignoreLoc = true;
3334
};
3435

3536
mqttSettingsJson = builtins.toJSON mqttSettings;

pkg/mqtt-controller/crates/mqtt-controller-frontend/src/app.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,43 @@ pub enum Tab {
1515
Heating,
1616
}
1717

18+
impl Tab {
19+
fn from_hash(hash: &str) -> Self {
20+
match hash.trim_start_matches('#') {
21+
"plugs" => Tab::Plugs,
22+
"heating" => Tab::Heating,
23+
_ => Tab::Rooms,
24+
}
25+
}
26+
27+
fn to_hash(self) -> &'static str {
28+
match self {
29+
Tab::Rooms => "#rooms",
30+
Tab::Plugs => "#plugs",
31+
Tab::Heating => "#heating",
32+
}
33+
}
34+
}
35+
1836
#[component]
1937
pub fn App() -> impl IntoView {
2038
let ws = WsState::new();
2139
provide_context(ws.clone());
2240

23-
let (active_tab, set_active_tab) = signal(Tab::Rooms);
41+
let initial_tab = web_sys::window()
42+
.and_then(|w| w.location().hash().ok())
43+
.map(|h| Tab::from_hash(&h))
44+
.unwrap_or(Tab::Rooms);
45+
46+
let (active_tab, set_active_tab) = signal(initial_tab);
47+
48+
// Sync tab changes to URL hash.
49+
Effect::new(move |_| {
50+
let tab = active_tab.get();
51+
if let Some(window) = web_sys::window() {
52+
let _ = window.location().set_hash(tab.to_hash());
53+
}
54+
});
2455
provide_context(active_tab);
2556

2657
// Auto-set entity filter based on active tab + topology.

pkg/mqtt-controller/crates/mqtt-controller-frontend/src/components/plug_card.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,22 @@ fn PlugCard(plug: PlugSnapshot) -> impl IntoView {
3535
let detail_name = plug.device.clone();
3636

3737
let status_text = if plug.on { "ON" } else { "OFF" };
38-
let idle_text = plug.idle_since_ago_ms.map(|ms| {
39-
let secs = ms / 1000;
40-
if secs < 60 {
41-
format!(" (idle {secs}s)")
42-
} else {
43-
format!(" (idle {}m)", secs / 60)
44-
}
45-
}).unwrap_or_default();
4638
let power_text = plug.power_watts.map(|w| format!(" {w:.1}W")).unwrap_or_default();
47-
let meta_text = format!("{status_text}{idle_text}{power_text}");
39+
let meta_text = format!("{status_text}{power_text}");
40+
41+
// Kill-switch countdown badge: shows remaining time before auto-off.
42+
let kill_switch_badge = plug.idle_since_ago_ms.and_then(|elapsed_ms| {
43+
let holdoff_secs = plug.kill_switch_holdoff_secs?;
44+
let elapsed_secs = elapsed_ms / 1000;
45+
let remaining = holdoff_secs.saturating_sub(elapsed_secs);
46+
let total_min = holdoff_secs / 60;
47+
let text = if remaining < 60 {
48+
format!("kill: {remaining}s / {total_min}m")
49+
} else {
50+
format!("kill: {}m / {total_min}m", remaining / 60)
51+
};
52+
Some(text)
53+
});
4854

4955
let json_text = serde_json::to_string_pretty(&plug).unwrap_or_default();
5056

@@ -89,6 +95,9 @@ fn PlugCard(plug: PlugSnapshot) -> impl IntoView {
8995
</div>
9096
<div class="card-meta">
9197
{meta_text}
98+
{kill_switch_badge.map(|text| view! {
99+
<span class="badge kill-switch">{text}</span>
100+
})}
92101
</div>
93102
<div class="card-controls">
94103
<button

pkg/mqtt-controller/crates/mqtt-controller-frontend/style.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ header h1 {
140140
.badge.idle { background: var(--idle); color: #000; }
141141
.badge.unknown { background: var(--off); color: var(--text); }
142142
.badge.inhibited { background: #6a5acd; color: #fff; }
143+
.badge.kill-switch { background: var(--accent); color: #fff; }
143144
.badge.schedule-badge { background: var(--border); cursor: pointer; }
144145

145146
.card-controls {

pkg/mqtt-controller/crates/mqtt-controller-wire/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ pub struct PlugSnapshot {
4040
pub on: bool,
4141
/// Milliseconds since the plug entered idle (power below threshold).
4242
pub idle_since_ago_ms: Option<u64>,
43+
/// Kill-switch holdoff duration in seconds. When `idle_since_ago_ms`
44+
/// is `Some`, this is the total holdoff the plug must survive before
45+
/// being turned off.
46+
#[serde(default, skip_serializing_if = "Option::is_none")]
47+
pub kill_switch_holdoff_secs: Option<u64>,
4348
/// Most recent power reading in watts, if available.
4449
#[serde(default, skip_serializing_if = "Option::is_none")]
4550
pub power_watts: Option<f64>,
@@ -274,6 +279,7 @@ mod tests {
274279
device: "z2m-p-printer".into(),
275280
on: true,
276281
idle_since_ago_ms: Some(30000),
282+
kill_switch_holdoff_secs: Some(600),
277283
power_watts: Some(120.5),
278284
}],
279285
heating_zones: vec![HeatingZoneSnapshot {

pkg/mqtt-controller/crates/mqtt-controller/src/controller/kill_switch.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,26 @@ impl KillSwitchEvaluator {
7070
.min()
7171
}
7272

73+
/// Maximum holdoff duration across all `PowerBelow` rules targeting
74+
/// `device` that are currently tracking idle. Returns `None` if no
75+
/// rule is idle.
76+
pub fn holdoff_secs(&self, device: &str) -> Option<u64> {
77+
self.topology
78+
.actions_for_power_below(device)
79+
.iter()
80+
.filter_map(|&idx| {
81+
let resolved = &self.topology.actions()[idx];
82+
if !self.idle_since.contains_key(&resolved.name) {
83+
return None;
84+
}
85+
match &resolved.trigger {
86+
Trigger::PowerBelow { for_seconds, .. } => Some(*for_seconds),
87+
_ => None,
88+
}
89+
})
90+
.max()
91+
}
92+
7393
/// Clear all kill-switch tracking for a device. Called on any
7494
/// off-transition (explicit off, toggle off, kill-switch fire from
7595
/// the controller side, etc.).

pkg/mqtt-controller/crates/mqtt-controller/src/controller/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,12 @@ impl Controller {
331331
self.kill_switch.earliest_idle(device)
332332
}
333333

334+
/// Maximum kill-switch holdoff duration (seconds) for idle rules
335+
/// targeting `device`.
336+
pub fn kill_switch_holdoff_secs(&self, device: &str) -> Option<u64> {
337+
self.kill_switch.holdoff_secs(device)
338+
}
339+
334340
/// Reference to the immutable topology.
335341
pub fn topology(&self) -> &Arc<Topology> {
336342
&self.topology

pkg/mqtt-controller/crates/mqtt-controller/src/web/snapshot.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@ pub fn build_full_snapshot(controller: &Controller, now: Instant) -> FullStateSn
3636
let idle_since_ago_ms = controller
3737
.earliest_kill_switch_idle(name)
3838
.map(|t| ago_ms(now, t));
39+
let kill_switch_holdoff_secs = controller.kill_switch_holdoff_secs(name);
3940
PlugSnapshot {
4041
device: name.clone(),
4142
on: state.map_or(false, |s| s.on),
4243
idle_since_ago_ms,
44+
kill_switch_holdoff_secs,
4345
power_watts: state.and_then(|s| s.last_power),
4446
}
4547
})
@@ -127,10 +129,12 @@ pub fn build_plug_snapshot(
127129
let idle_since_ago_ms = controller
128130
.earliest_kill_switch_idle(device)
129131
.map(|t| ago_ms(now, t));
132+
let kill_switch_holdoff_secs = controller.kill_switch_holdoff_secs(device);
130133
Some(PlugSnapshot {
131134
device: device.to_string(),
132135
on: state.on,
133136
idle_since_ago_ms,
137+
kill_switch_holdoff_secs,
134138
power_watts: state.last_power,
135139
})
136140
}

0 commit comments

Comments
 (0)