Skip to content

Commit f3e22eb

Browse files
tim-irelandclaude
andcommitted
feat: add /api/v1/snapshot JSON endpoint and wire TUI to real panel data
Fixes three data gaps in the TUI: - HVAC mode was hardcoded to "HEAT" (now real operating mode from metric cache) - solar_today_kwh was always 0 (now computed from cache via snapshot) - Vehicle SoC and charge_kw were always 0/None (now real values) API change: - New GET /api/v1/snapshot on dashboard_routes (unauthenticated) returns SnapshotResponse with solar, HVAC, vehicle, energy, weather, and efficiency_score in a single JSON payload TUI changes: - FluxClient.fetch_snapshot() calls /api/v1/snapshot - AppState.apply_snapshot() overwrites all scalar values from snapshot; apply_connectors() is now a fallback used only when snapshot data is absent - HvacUnit gains zone_temp_f, setpoint_f, total_power_w fields - Vehicle gains range_miles field - HVAC panel now shows: NAME | MODE | ROOM→SET | COP | POWER | STATUS - Header now shows outdoor temp and efficiency score when available Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent eddabf2 commit f3e22eb

6 files changed

Lines changed: 447 additions & 16 deletions

File tree

bins/flux-tui/src/app.rs

Lines changed: 183 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ pub struct HvacUnit {
4141
pub mode: String,
4242
/// Coefficient of Performance, if available.
4343
pub cop: Option<f64>,
44+
/// Zone (room) air temperature in °F, if available.
45+
pub zone_temp_f: Option<f64>,
46+
/// Active thermostat setpoint in °F, if available.
47+
pub setpoint_f: Option<f64>,
48+
/// Total electrical power draw in watts, if available.
49+
pub total_power_w: Option<f64>,
4450
/// Derived status string (e.g. `"NOMINAL"`, `"OFFLINE"`).
4551
pub status: String,
4652
}
@@ -56,6 +62,9 @@ pub struct Vehicle {
5662
pub charging_state: String,
5763
/// Current charge power in kW, if actively charging.
5864
pub charge_kw: Option<f64>,
65+
/// Estimated range in miles, if available.
66+
#[allow(dead_code)] // Stored for future range display in vehicle detail screen.
67+
pub range_miles: Option<f64>,
5968
}
6069

6170
/// Complete data snapshot for one render tick.
@@ -76,8 +85,11 @@ pub struct DashboardSnapshot {
7685
/// All vehicles visible in the metric cache.
7786
pub vehicles: Vec<Vehicle>,
7887
/// Current outdoor temperature in °F, if available.
79-
#[allow(dead_code)] // Populated by weather connector; displayed in future weather panel.
8088
pub weather_temp_f: Option<f64>,
89+
/// Weighted-average COP across all active HVAC units, if available.
90+
pub overall_cop: Option<f64>,
91+
/// Composite efficiency score (0–100), if available.
92+
pub efficiency_score: Option<f64>,
8193
/// Whether the fluxd API responded successfully on the last poll.
8294
pub api_connected: bool,
8395
/// API server version string.
@@ -181,7 +193,17 @@ impl AppState {
181193
}
182194

183195
/// Populate HVAC and vehicle lists from the connector roster returned by the API.
196+
///
197+
/// This provides a minimal fallback (name + online/stale status) when the
198+
/// snapshot endpoint is unavailable. [`Self::apply_snapshot`] overwrites
199+
/// this data with full metric values when the snapshot fetch succeeds.
184200
pub fn apply_connectors(&mut self, connectors: Vec<crate::client::ConnectorInfo>) {
201+
// Only populate as fallback when snapshot has not already provided data.
202+
// If we already have real metric data from apply_snapshot, keep it.
203+
if !self.snapshot.hvac_units.is_empty() || !self.snapshot.vehicles.is_empty() {
204+
return;
205+
}
206+
185207
let mut hvac_units: Vec<HvacUnit> = Vec::new();
186208
let mut vehicles: Vec<Vehicle> = Vec::new();
187209

@@ -195,15 +217,19 @@ impl AppState {
195217
match c.category.to_lowercase().as_str() {
196218
"hvac" => hvac_units.push(HvacUnit {
197219
name: c.name.clone(),
198-
mode: "HEAT".to_string(), // placeholder — real mode comes from metric fields
220+
mode: "UNKNOWN".to_string(),
199221
cop: None,
222+
zone_temp_f: None,
223+
setpoint_f: None,
224+
total_power_w: None,
200225
status,
201226
}),
202227
"ev" | "vehicle" => vehicles.push(Vehicle {
203228
name: c.name.clone(),
204229
soc_pct: 0.0,
205230
charging_state: status.clone(),
206231
charge_kw: None,
232+
range_miles: None,
207233
}),
208234
_ => {}
209235
}
@@ -212,6 +238,47 @@ impl AppState {
212238
self.snapshot.hvac_units = hvac_units;
213239
self.snapshot.vehicles = vehicles;
214240
}
241+
242+
/// Apply a full [`crate::client::SnapshotData`] response into the app state.
243+
///
244+
/// Overwrites scalar kW values, HVAC units (with real mode/COP/temperatures),
245+
/// vehicles (with real SoC and charge data), weather temp, and efficiency score.
246+
pub fn apply_snapshot(&mut self, data: crate::client::SnapshotData) {
247+
self.snapshot.solar_kw = data.solar_kw;
248+
self.snapshot.solar_today_kwh = data.solar_today_kwh;
249+
self.snapshot.load_kw = data.home_kw;
250+
self.snapshot.grid_kw = data.grid_kw;
251+
self.snapshot.grid_exporting = data.grid_exporting;
252+
self.snapshot.overall_cop = data.overall_cop;
253+
self.snapshot.efficiency_score = data.efficiency_score;
254+
self.snapshot.weather_temp_f = data.weather_temp_f;
255+
256+
self.snapshot.hvac_units = data
257+
.hvac_units
258+
.into_iter()
259+
.map(|u| HvacUnit {
260+
name: u.name,
261+
mode: u.mode,
262+
cop: u.cop,
263+
zone_temp_f: u.zone_temp_f,
264+
setpoint_f: u.setpoint_f,
265+
total_power_w: u.total_power_w,
266+
status: u.status,
267+
})
268+
.collect();
269+
270+
self.snapshot.vehicles = data
271+
.vehicles
272+
.into_iter()
273+
.map(|v| Vehicle {
274+
name: v.name,
275+
soc_pct: v.soc_pct,
276+
charging_state: v.charging_state,
277+
charge_kw: v.charge_kw,
278+
range_miles: v.range_miles,
279+
})
280+
.collect();
281+
}
215282
}
216283

217284
/// Return a human-readable staleness string for a given age in seconds.
@@ -305,4 +372,118 @@ mod tests {
305372
fn staleness_label_hours() {
306373
assert_eq!(staleness_label(3600), "1h ago");
307374
}
375+
376+
fn make_snapshot(
377+
solar_kw: f64,
378+
solar_today_kwh: f64,
379+
home_kw: f64,
380+
grid_kw: f64,
381+
grid_exporting: bool,
382+
) -> crate::client::SnapshotData {
383+
crate::client::SnapshotData {
384+
solar_kw,
385+
solar_today_kwh,
386+
home_kw,
387+
grid_kw,
388+
grid_exporting,
389+
hvac_units: vec![],
390+
overall_cop: None,
391+
vehicles: vec![],
392+
weather_temp_f: None,
393+
efficiency_score: None,
394+
}
395+
}
396+
397+
#[test]
398+
fn apply_snapshot_sets_scalar_kw_values() {
399+
let mut state = AppState::new();
400+
state.apply_snapshot(make_snapshot(8.5, 32.1, 4.2, 4.3, true));
401+
assert!((state.snapshot.solar_kw - 8.5).abs() < f64::EPSILON);
402+
assert!((state.snapshot.solar_today_kwh - 32.1).abs() < f64::EPSILON);
403+
assert!((state.snapshot.load_kw - 4.2).abs() < f64::EPSILON);
404+
assert!((state.snapshot.grid_kw - 4.3).abs() < f64::EPSILON);
405+
assert!(state.snapshot.grid_exporting);
406+
}
407+
408+
#[test]
409+
fn apply_snapshot_populates_hvac_units() {
410+
let mut state = AppState::new();
411+
let mut snap = make_snapshot(0.0, 0.0, 0.0, 0.0, false);
412+
snap.hvac_units = vec![
413+
crate::client::HvacUnitData {
414+
name: "zone1".to_string(),
415+
mode: "HEAT".to_string(),
416+
cop: Some(3.8),
417+
zone_temp_f: Some(71.0),
418+
setpoint_f: Some(70.0),
419+
total_power_w: Some(900.0),
420+
status: "NOMINAL".to_string(),
421+
},
422+
crate::client::HvacUnitData {
423+
name: "zone2".to_string(),
424+
mode: "IDLE".to_string(),
425+
cop: None,
426+
zone_temp_f: None,
427+
setpoint_f: None,
428+
total_power_w: None,
429+
status: "NOMINAL".to_string(),
430+
},
431+
];
432+
snap.overall_cop = Some(3.8);
433+
state.apply_snapshot(snap);
434+
assert_eq!(state.snapshot.hvac_units.len(), 2);
435+
assert_eq!(state.snapshot.hvac_units[0].name, "zone1");
436+
assert_eq!(state.snapshot.hvac_units[0].mode, "HEAT");
437+
assert!((state.snapshot.hvac_units[0].cop.unwrap() - 3.8).abs() < f64::EPSILON);
438+
assert!((state.snapshot.hvac_units[0].zone_temp_f.unwrap() - 71.0).abs() < f64::EPSILON);
439+
assert_eq!(state.snapshot.hvac_units[1].mode, "IDLE");
440+
assert!(state.snapshot.hvac_units[1].cop.is_none());
441+
assert!((state.snapshot.overall_cop.unwrap() - 3.8).abs() < f64::EPSILON);
442+
}
443+
444+
#[test]
445+
fn apply_snapshot_populates_vehicles() {
446+
let mut state = AppState::new();
447+
let mut snap = make_snapshot(0.0, 0.0, 0.0, 0.0, false);
448+
snap.vehicles = vec![crate::client::VehicleData {
449+
name: "Model 3".to_string(),
450+
soc_pct: 78.0,
451+
charging_state: "Charging".to_string(),
452+
charge_kw: Some(11.5),
453+
range_miles: Some(220.0),
454+
}];
455+
state.apply_snapshot(snap);
456+
assert_eq!(state.snapshot.vehicles.len(), 1);
457+
assert_eq!(state.snapshot.vehicles[0].name, "Model 3");
458+
assert!((state.snapshot.vehicles[0].soc_pct - 78.0).abs() < f64::EPSILON);
459+
assert!((state.snapshot.vehicles[0].charge_kw.unwrap() - 11.5).abs() < f64::EPSILON);
460+
assert!((state.snapshot.vehicles[0].range_miles.unwrap() - 220.0).abs() < f64::EPSILON);
461+
}
462+
463+
#[test]
464+
fn apply_connectors_skips_when_snapshot_data_present() {
465+
let mut state = AppState::new();
466+
// First apply snapshot with real HVAC data.
467+
let mut snap = make_snapshot(0.0, 0.0, 0.0, 0.0, false);
468+
snap.hvac_units = vec![crate::client::HvacUnitData {
469+
name: "zone1".to_string(),
470+
mode: "HEAT".to_string(),
471+
cop: Some(3.5),
472+
zone_temp_f: Some(70.0),
473+
setpoint_f: Some(69.0),
474+
total_power_w: Some(800.0),
475+
status: "NOMINAL".to_string(),
476+
}];
477+
state.apply_snapshot(snap);
478+
// Now apply connectors — should not overwrite the real snapshot data.
479+
state.apply_connectors(vec![crate::client::ConnectorInfo {
480+
name: "wf1".to_string(),
481+
category: "Hvac".to_string(),
482+
last_seen: None,
483+
status: Some("online".to_string()),
484+
}]);
485+
// Real mode from snapshot should be preserved, not replaced by placeholder.
486+
assert_eq!(state.snapshot.hvac_units[0].mode, "HEAT");
487+
assert!(state.snapshot.hvac_units[0].cop.is_some());
488+
}
308489
}

0 commit comments

Comments
 (0)