This commit is contained in:
Mateusz Gruszczyński
2026-09-15 09:04:29 +02:00
parent 0a2fb8c6c7
commit 2ff310bdc5
37 changed files with 731 additions and 141 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
+25 -2
View File
@@ -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)
}
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+2 -1
View File
@@ -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;