v0.14.2
This commit is contained in:
@@ -27,7 +27,7 @@ async fn export_configuration(
|
||||
|
||||
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
if export.format_version != 3 {
|
||||
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.1".into()));
|
||||
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.2".into()));
|
||||
}
|
||||
if export.settings.control_strategy != "setpoint" {
|
||||
return Err(AppError::BadRequest(
|
||||
|
||||
+5
-1
@@ -1107,7 +1107,9 @@ fn compile_flow(
|
||||
&& native_time_range
|
||||
&& preset_for_schedule != "auto"
|
||||
&& mode_for_schedule == "auto"
|
||||
&& flow_bool(&action_node.config, "power").is_none();
|
||||
&& flow_bool(&action_node.config, "power").is_none()
|
||||
&& flow_bool(&action_node.config, "swing_vertical").is_none()
|
||||
&& flow_bool(&action_node.config, "swing_horizontal").is_none();
|
||||
if schedule_only {
|
||||
let zone_id = flow_string(&action_node.config, "zone_id")
|
||||
.ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?;
|
||||
@@ -1285,6 +1287,8 @@ fn compile_flow(
|
||||
}
|
||||
item.action.mode = Some(mode);
|
||||
}
|
||||
item.action.swing_vertical = flow_bool(&action_node.config, "swing_vertical");
|
||||
item.action.swing_horizontal = flow_bool(&action_node.config, "swing_horizontal");
|
||||
}
|
||||
"device_action" => {
|
||||
let id = flow_string(&action_node.config, "device_id")
|
||||
|
||||
+78
-1
@@ -20,12 +20,81 @@ struct SystemInfoResponse {
|
||||
gree_received_frames_by_device: std::collections::HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct DeviceGroupEnergySnapshot {
|
||||
total_kwh: f64,
|
||||
timestamp: Option<chrono::DateTime<Utc>>,
|
||||
source: String,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
fn device_group_energy_snapshot(
|
||||
state: &AppState,
|
||||
group: &DeviceGroup,
|
||||
devices: &[Device],
|
||||
) -> Result<Option<DeviceGroupEnergySnapshot>, AppError> {
|
||||
let selected_source = match group.energy_source {
|
||||
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
|
||||
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
|
||||
EnergySourcePreference::Auto if group.energy_device_id.is_some() => Some("gree_cloud"),
|
||||
EnergySourcePreference::Auto if group.ha_energy_entity_id.is_some() => Some("home_assistant"),
|
||||
EnergySourcePreference::Auto => None,
|
||||
};
|
||||
|
||||
match selected_source {
|
||||
Some("gree_cloud") => {
|
||||
let Some(device_id) = group.energy_device_id.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(device) = devices.iter().find(|device| device.id == device_id) {
|
||||
if let Some(total_kwh) = device
|
||||
.total_energy_kwh
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
{
|
||||
return Ok(Some(DeviceGroupEnergySnapshot {
|
||||
total_kwh,
|
||||
timestamp: device
|
||||
.last_cloud_sync
|
||||
.clone()
|
||||
.or_else(|| device.last_seen.clone()),
|
||||
source: "gree_cloud".into(),
|
||||
origin: "cloud".into(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(state
|
||||
.db
|
||||
.last_energy_reading(device_id, "gree_cloud")?
|
||||
.map(|reading| DeviceGroupEnergySnapshot {
|
||||
total_kwh: reading.normalized_meter_kwh,
|
||||
timestamp: Some(reading.timestamp),
|
||||
source: "gree_cloud".into(),
|
||||
origin: "database".into(),
|
||||
}))
|
||||
}
|
||||
Some("home_assistant") => {
|
||||
let storage_id = format!("group:{}", group.id);
|
||||
Ok(state
|
||||
.db
|
||||
.last_energy_reading(&storage_id, "home_assistant")?
|
||||
.map(|reading| DeviceGroupEnergySnapshot {
|
||||
total_kwh: reading.normalized_meter_kwh,
|
||||
timestamp: Some(reading.timestamp),
|
||||
source: "home_assistant".into(),
|
||||
origin: "database".into(),
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct BootstrapResponse {
|
||||
devices: Vec<Device>,
|
||||
zones: Vec<Zone>,
|
||||
groups: Vec<ClimateGroup>,
|
||||
device_groups: Vec<DeviceGroup>,
|
||||
device_group_energy: std::collections::HashMap<String, DeviceGroupEnergySnapshot>,
|
||||
schedules: Vec<Schedule>,
|
||||
automations: Vec<Automation>,
|
||||
flows: Vec<Flow>,
|
||||
@@ -59,6 +128,13 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
|
||||
(settings_snapshot(&settings), settings.house_mode.clone())
|
||||
};
|
||||
let devices = state.db.list_devices()?;
|
||||
let device_groups = state.db.list_device_groups()?;
|
||||
let mut device_group_energy = std::collections::HashMap::new();
|
||||
for group in &device_groups {
|
||||
if let Some(snapshot) = device_group_energy_snapshot(state, group, &devices)? {
|
||||
device_group_energy.insert(group.id.clone(), snapshot);
|
||||
}
|
||||
}
|
||||
let system = build_system_info(state, &devices);
|
||||
let control_plan = engine::get_control_plan_snapshot(state).await?;
|
||||
|
||||
@@ -66,7 +142,8 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
|
||||
devices,
|
||||
zones: state.db.list_zones()?,
|
||||
groups: state.db.list_groups()?,
|
||||
device_groups: state.db.list_device_groups()?,
|
||||
device_groups,
|
||||
device_group_energy,
|
||||
schedules: state.db.list_schedules()?,
|
||||
automations: state.db.list_automations()?,
|
||||
flows: state.db.list_flows()?,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading, EnergySourcePreference,
|
||||
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, DeviceGroup, EnergyReading, EnergySourcePreference,
|
||||
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
|
||||
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
|
||||
},
|
||||
|
||||
@@ -897,13 +897,36 @@ async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option<
|
||||
zone.control_reason = "Visual Flow automation".into();
|
||||
}
|
||||
refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.wake_zone_control();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
|
||||
let swing_command = DeviceCommand {
|
||||
swing_vertical: action.swing_vertical,
|
||||
swing_horizontal: action.swing_horizontal,
|
||||
..Default::default()
|
||||
};
|
||||
if action.power == Some(false) {
|
||||
// Disabled zones are intentionally skipped by the normal thermostat cycle. Perform the
|
||||
// physical OFF under the canonical zone -> device lock order so the durable Flow intent
|
||||
// cannot leave a unit running and polling/manual control cannot interleave with the frame.
|
||||
// Swing can safely share this explicit frame because it is outside thermostat regulation.
|
||||
let _device_guard = state.lock_device_operation(&device_id).await;
|
||||
send_command_locked_forced(state, &device_id, DeviceCommand { power: Some(false), ..Default::default() }).await?;
|
||||
send_command_locked_forced(
|
||||
state,
|
||||
&device_id,
|
||||
DeviceCommand {
|
||||
power: Some(false),
|
||||
swing_vertical: swing_command.swing_vertical,
|
||||
swing_horizontal: swing_command.swing_horizontal,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else if !swing_command.is_empty() {
|
||||
// Swing is intentionally a one-shot auxiliary unit setting. It does not participate in
|
||||
// temperature/fan regulation, so the thermostat keeps ownership of the zone.
|
||||
let _ = send_automatic_device_command_if_owned(state, &device_id, swing_command).await?;
|
||||
}
|
||||
state.wake_zone_control();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ pub(crate) fn record_cumulative_energy_sample(
|
||||
reset_detected,
|
||||
};
|
||||
state.db.add_energy_reading(&reading)?;
|
||||
state.broadcast(
|
||||
"energy.updated",
|
||||
json!({
|
||||
"target_id": reading.device_id.clone(),
|
||||
"source": reading.source.clone(),
|
||||
"total_kwh": reading.normalized_meter_kwh,
|
||||
"timestamp": reading.timestamp,
|
||||
}),
|
||||
);
|
||||
queue_influx_energy(state, reading.clone());
|
||||
Ok(reading)
|
||||
}
|
||||
|
||||
+51
-7
@@ -121,13 +121,7 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) {
|
||||
});
|
||||
}
|
||||
|
||||
fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
|
||||
let mut values: Vec<f64> = devices
|
||||
.iter()
|
||||
.filter(|device| device.enabled && device.online && device.communication_failures == 0)
|
||||
.filter_map(|device| device.outdoor_temperature)
|
||||
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
|
||||
.collect();
|
||||
fn median_outdoor_temperature(mut values: Vec<f64>) -> Option<f64> {
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -140,3 +134,53 @@ fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
|
||||
};
|
||||
Some((value * 10.0).round() / 10.0)
|
||||
}
|
||||
|
||||
fn valid_outdoor_temperature(device: &Device) -> Option<f64> {
|
||||
if !device.enabled || !device.online || device.communication_failures != 0 {
|
||||
return None;
|
||||
}
|
||||
device
|
||||
.outdoor_temperature
|
||||
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
|
||||
}
|
||||
|
||||
fn gree_outdoor_temperature(devices: &[Device], device_groups: &[DeviceGroup]) -> Option<f64> {
|
||||
let by_id: HashMap<&str, &Device> = devices
|
||||
.iter()
|
||||
.map(|device| (device.id.as_str(), device))
|
||||
.collect();
|
||||
let mut grouped_ids = HashSet::new();
|
||||
let mut values = Vec::new();
|
||||
|
||||
for group in device_groups {
|
||||
for device_id in &group.device_ids {
|
||||
grouped_ids.insert(device_id.as_str());
|
||||
}
|
||||
let group_value = group
|
||||
.outdoor_temperature_device_id
|
||||
.as_deref()
|
||||
.and_then(|source_id| by_id.get(source_id).copied())
|
||||
.and_then(valid_outdoor_temperature)
|
||||
.or_else(|| {
|
||||
median_outdoor_temperature(
|
||||
group
|
||||
.device_ids
|
||||
.iter()
|
||||
.filter_map(|id| by_id.get(id.as_str()).copied())
|
||||
.filter_map(valid_outdoor_temperature)
|
||||
.collect(),
|
||||
)
|
||||
});
|
||||
if let Some(value) = group_value {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
values.extend(
|
||||
devices
|
||||
.iter()
|
||||
.filter(|device| !grouped_ids.contains(device.id.as_str()))
|
||||
.filter_map(valid_outdoor_temperature),
|
||||
);
|
||||
median_outdoor_temperature(values)
|
||||
}
|
||||
|
||||
+31
-1
@@ -352,6 +352,7 @@ mod tests {
|
||||
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("dry".into()), ..Default::default() }));
|
||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { mode: Some("heat".into()), ..Default::default() }));
|
||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() }));
|
||||
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { swing_vertical: Some(true), swing_horizontal: Some(false), ..Default::default() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -988,9 +989,38 @@ mod tests {
|
||||
let mut c = Device::simulated_default();
|
||||
c.id = "sim-c".into();
|
||||
c.outdoor_temperature = Some(40.0);
|
||||
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
|
||||
assert_eq!(gree_outdoor_temperature(&[a, b, c], &[]), Some(12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gree_outdoor_fallback_counts_multisplit_once() {
|
||||
let mut a = Device::simulated_default();
|
||||
a.id = "a".into();
|
||||
a.outdoor_temperature = Some(10.0);
|
||||
let mut b = Device::simulated_default();
|
||||
b.id = "b".into();
|
||||
b.outdoor_temperature = Some(10.0);
|
||||
let mut c = Device::simulated_default();
|
||||
c.id = "c".into();
|
||||
c.outdoor_temperature = Some(30.0);
|
||||
let now = Utc::now();
|
||||
let group = DeviceGroup {
|
||||
id: "multi".into(),
|
||||
name: "Multi".into(),
|
||||
kind: crate::models::DeviceGroupKind::Multisplit,
|
||||
device_ids: vec!["a".into(), "b".into()],
|
||||
energy_source: EnergySourcePreference::Auto,
|
||||
energy_device_id: None,
|
||||
ha_energy_entity_id: None,
|
||||
ha_energy_unit: None,
|
||||
ha_energy_device_class: None,
|
||||
ha_energy_state_class: None,
|
||||
outdoor_temperature_device_id: Some("a".into()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
assert_eq!(gree_outdoor_temperature(&[a, b, c], &[group]), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_one_shot_queue_does_not_change_zone_ownership() {
|
||||
|
||||
@@ -76,7 +76,8 @@ async fn resolve_cycle_outdoor_temperature(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices));
|
||||
let device_groups = state.db.list_device_groups().unwrap_or_default();
|
||||
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices, &device_groups));
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != temperature {
|
||||
*current = temperature;
|
||||
|
||||
Reference in New Issue
Block a user