Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 183 additions & 2 deletions bins/flux-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ pub struct HvacUnit {
pub mode: String,
/// Coefficient of Performance, if available.
pub cop: Option<f64>,
/// Zone (room) air temperature in °F, if available.
pub zone_temp_f: Option<f64>,
/// Active thermostat setpoint in °F, if available.
pub setpoint_f: Option<f64>,
/// Total electrical power draw in watts, if available.
pub total_power_w: Option<f64>,
/// Derived status string (e.g. `"NOMINAL"`, `"OFFLINE"`).
pub status: String,
}
Expand All @@ -56,6 +62,9 @@ pub struct Vehicle {
pub charging_state: String,
/// Current charge power in kW, if actively charging.
pub charge_kw: Option<f64>,
/// Estimated range in miles, if available.
#[allow(dead_code)] // Stored for future range display in vehicle detail screen.
pub range_miles: Option<f64>,
}

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

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

let mut hvac_units: Vec<HvacUnit> = Vec::new();
let mut vehicles: Vec<Vehicle> = Vec::new();

Expand All @@ -195,15 +217,19 @@ impl AppState {
match c.category.to_lowercase().as_str() {
"hvac" => hvac_units.push(HvacUnit {
name: c.name.clone(),
mode: "HEAT".to_string(), // placeholder — real mode comes from metric fields
mode: "UNKNOWN".to_string(),
cop: None,
zone_temp_f: None,
setpoint_f: None,
total_power_w: None,
status,
}),
"ev" | "vehicle" => vehicles.push(Vehicle {
name: c.name.clone(),
soc_pct: 0.0,
charging_state: status.clone(),
charge_kw: None,
range_miles: None,
}),
_ => {}
}
Expand All @@ -212,6 +238,47 @@ impl AppState {
self.snapshot.hvac_units = hvac_units;
self.snapshot.vehicles = vehicles;
}

/// Apply a full [`crate::client::SnapshotData`] response into the app state.
///
/// Overwrites scalar kW values, HVAC units (with real mode/COP/temperatures),
/// vehicles (with real SoC and charge data), weather temp, and efficiency score.
pub fn apply_snapshot(&mut self, data: crate::client::SnapshotData) {
self.snapshot.solar_kw = data.solar_kw;
self.snapshot.solar_today_kwh = data.solar_today_kwh;
self.snapshot.load_kw = data.home_kw;
self.snapshot.grid_kw = data.grid_kw;
self.snapshot.grid_exporting = data.grid_exporting;
self.snapshot.overall_cop = data.overall_cop;
self.snapshot.efficiency_score = data.efficiency_score;
self.snapshot.weather_temp_f = data.weather_temp_f;

self.snapshot.hvac_units = data
.hvac_units
.into_iter()
.map(|u| HvacUnit {
name: u.name,
mode: u.mode,
cop: u.cop,
zone_temp_f: u.zone_temp_f,
setpoint_f: u.setpoint_f,
total_power_w: u.total_power_w,
status: u.status,
})
.collect();

self.snapshot.vehicles = data
.vehicles
.into_iter()
.map(|v| Vehicle {
name: v.name,
soc_pct: v.soc_pct,
charging_state: v.charging_state,
charge_kw: v.charge_kw,
range_miles: v.range_miles,
})
.collect();
}
}

/// Return a human-readable staleness string for a given age in seconds.
Expand Down Expand Up @@ -305,4 +372,118 @@ mod tests {
fn staleness_label_hours() {
assert_eq!(staleness_label(3600), "1h ago");
}

fn make_snapshot(
solar_kw: f64,
solar_today_kwh: f64,
home_kw: f64,
grid_kw: f64,
grid_exporting: bool,
) -> crate::client::SnapshotData {
crate::client::SnapshotData {
solar_kw,
solar_today_kwh,
home_kw,
grid_kw,
grid_exporting,
hvac_units: vec![],
overall_cop: None,
vehicles: vec![],
weather_temp_f: None,
efficiency_score: None,
}
}

#[test]
fn apply_snapshot_sets_scalar_kw_values() {
let mut state = AppState::new();
state.apply_snapshot(make_snapshot(8.5, 32.1, 4.2, 4.3, true));
assert!((state.snapshot.solar_kw - 8.5).abs() < f64::EPSILON);
assert!((state.snapshot.solar_today_kwh - 32.1).abs() < f64::EPSILON);
assert!((state.snapshot.load_kw - 4.2).abs() < f64::EPSILON);
assert!((state.snapshot.grid_kw - 4.3).abs() < f64::EPSILON);
assert!(state.snapshot.grid_exporting);
}

#[test]
fn apply_snapshot_populates_hvac_units() {
let mut state = AppState::new();
let mut snap = make_snapshot(0.0, 0.0, 0.0, 0.0, false);
snap.hvac_units = vec![
crate::client::HvacUnitData {
name: "zone1".to_string(),
mode: "HEAT".to_string(),
cop: Some(3.8),
zone_temp_f: Some(71.0),
setpoint_f: Some(70.0),
total_power_w: Some(900.0),
status: "NOMINAL".to_string(),
},
crate::client::HvacUnitData {
name: "zone2".to_string(),
mode: "IDLE".to_string(),
cop: None,
zone_temp_f: None,
setpoint_f: None,
total_power_w: None,
status: "NOMINAL".to_string(),
},
];
snap.overall_cop = Some(3.8);
state.apply_snapshot(snap);
assert_eq!(state.snapshot.hvac_units.len(), 2);
assert_eq!(state.snapshot.hvac_units[0].name, "zone1");
assert_eq!(state.snapshot.hvac_units[0].mode, "HEAT");
assert!((state.snapshot.hvac_units[0].cop.unwrap() - 3.8).abs() < f64::EPSILON);
assert!((state.snapshot.hvac_units[0].zone_temp_f.unwrap() - 71.0).abs() < f64::EPSILON);
assert_eq!(state.snapshot.hvac_units[1].mode, "IDLE");
assert!(state.snapshot.hvac_units[1].cop.is_none());
assert!((state.snapshot.overall_cop.unwrap() - 3.8).abs() < f64::EPSILON);
}

#[test]
fn apply_snapshot_populates_vehicles() {
let mut state = AppState::new();
let mut snap = make_snapshot(0.0, 0.0, 0.0, 0.0, false);
snap.vehicles = vec![crate::client::VehicleData {
name: "Model 3".to_string(),
soc_pct: 78.0,
charging_state: "Charging".to_string(),
charge_kw: Some(11.5),
range_miles: Some(220.0),
}];
state.apply_snapshot(snap);
assert_eq!(state.snapshot.vehicles.len(), 1);
assert_eq!(state.snapshot.vehicles[0].name, "Model 3");
assert!((state.snapshot.vehicles[0].soc_pct - 78.0).abs() < f64::EPSILON);
assert!((state.snapshot.vehicles[0].charge_kw.unwrap() - 11.5).abs() < f64::EPSILON);
assert!((state.snapshot.vehicles[0].range_miles.unwrap() - 220.0).abs() < f64::EPSILON);
}

#[test]
fn apply_connectors_skips_when_snapshot_data_present() {
let mut state = AppState::new();
// First apply snapshot with real HVAC data.
let mut snap = make_snapshot(0.0, 0.0, 0.0, 0.0, false);
snap.hvac_units = vec![crate::client::HvacUnitData {
name: "zone1".to_string(),
mode: "HEAT".to_string(),
cop: Some(3.5),
zone_temp_f: Some(70.0),
setpoint_f: Some(69.0),
total_power_w: Some(800.0),
status: "NOMINAL".to_string(),
}];
state.apply_snapshot(snap);
// Now apply connectors — should not overwrite the real snapshot data.
state.apply_connectors(vec![crate::client::ConnectorInfo {
name: "wf1".to_string(),
category: "Hvac".to_string(),
last_seen: None,
status: Some("online".to_string()),
}]);
// Real mode from snapshot should be preserved, not replaced by placeholder.
assert_eq!(state.snapshot.hvac_units[0].mode, "HEAT");
assert!(state.snapshot.hvac_units[0].cop.is_some());
}
}
Loading
Loading