@@ -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