This commit is contained in:
Mateusz Gruszczyński
2026-09-17 11:21:49 +02:00
parent 32f8a32bd2
commit df10ead47e
38 changed files with 655 additions and 210 deletions
+2 -2
View File
@@ -594,8 +594,8 @@ fn sanitize_imported_device(device: &mut Device, now: chrono::DateTime<Utc>) {
device.mode = "cool".into();
device.target_temperature = 23.0;
device.fan_speed = 0;
device.swing_vertical = false;
device.swing_horizontal = false;
device.swing_vertical = 0;
device.swing_horizontal = 0;
device.quiet = false;
device.turbo = false;
device.light = false;
+2 -2
View File
@@ -116,8 +116,8 @@ async fn add_device(
mode: "cool".into(),
target_temperature: 24.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
swing_vertical: 0,
swing_horizontal: 0,
quiet: false,
quiet_wire_value: None,
turbo: false,
+22 -8
View File
@@ -53,6 +53,15 @@ fn flow_u8(config: &Value, key: &str) -> Option<u8> {
fn flow_bool(config: &Value, key: &str) -> Option<bool> {
config.get(key).and_then(Value::as_bool)
}
fn flow_louver_position(config: &Value, key: &str) -> Option<u8> {
match config.get(key) {
Some(Value::Bool(value)) => Some(u8::from(*value)),
Some(value) => value
.as_u64()
.and_then(|raw| u8::try_from(raw).ok()),
None => None,
}
}
fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String {
let digest = Sha256::digest(format!("{flow_id}:{action_node_id}").as_bytes());
@@ -1108,8 +1117,8 @@ fn compile_flow(
&& preset_for_schedule != "auto"
&& mode_for_schedule == "auto"
&& 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();
&& flow_louver_position(&action_node.config, "swing_vertical").is_none()
&& flow_louver_position(&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()))?;
@@ -1287,8 +1296,11 @@ 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");
item.action.swing_vertical =
flow_louver_position(&action_node.config, "swing_vertical");
item.action.swing_horizontal =
flow_louver_position(&action_node.config, "swing_horizontal");
engine::validate_command(&item.action)?;
}
"device_action" => {
let id = flow_string(&action_node.config, "device_id")
@@ -1304,8 +1316,10 @@ fn compile_flow(
item.action.target_temperature =
flow_f64(&action_node.config, "target_temperature");
item.action.fan_speed = flow_u8(&action_node.config, "fan_speed");
item.action.swing_vertical = flow_bool(&action_node.config, "swing_vertical");
item.action.swing_horizontal = flow_bool(&action_node.config, "swing_horizontal");
item.action.swing_vertical =
flow_louver_position(&action_node.config, "swing_vertical");
item.action.swing_horizontal =
flow_louver_position(&action_node.config, "swing_horizontal");
item.action.quiet = flow_bool(&action_node.config, "quiet");
item.action.turbo = flow_bool(&action_node.config, "turbo");
item.action.light = flow_bool(&action_node.config, "light");
@@ -1733,8 +1747,8 @@ fn dry_run_block_reason(
command.mode = flow_string(&action.config, "mode");
command.target_temperature = flow_f64(&action.config, "target_temperature");
command.fan_speed = flow_u8(&action.config, "fan_speed");
command.swing_vertical = flow_bool(&action.config, "swing_vertical");
command.swing_horizontal = flow_bool(&action.config, "swing_horizontal");
command.swing_vertical = flow_louver_position(&action.config, "swing_vertical");
command.swing_horizontal = flow_louver_position(&action.config, "swing_horizontal");
command.quiet = flow_bool(&action.config, "quiet");
command.turbo = flow_bool(&action.config, "turbo");
command.light = flow_bool(&action.config, "light");
+2 -2
View File
@@ -300,8 +300,8 @@ async fn add_gree_cloud_device(
mode: "cool".into(),
target_temperature: 24.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
swing_vertical: 0,
swing_horizontal: 0,
quiet: false,
quiet_wire_value: None,
turbo: false,
+1
View File
@@ -4,6 +4,7 @@ use crate::{
models::{
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType,
ControlPlan, ControlPlanEvent, Device, DeviceCommand, DeviceGroup, EnergyReading,
HORIZONTAL_SWING_MAX, VERTICAL_SWING_MAX,
EnergySourcePreference, GroupControlPatch, HaReading, NetworkReading, NightModeSettings,
Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan,
ZoneReading,
+20 -2
View File
@@ -264,6 +264,18 @@ fn flow_compare_value(actual: &Value, operator: &str, expected: &Value) -> bool
if operator == "neq" { !equal } else { equal }
}
fn flow_louver_compat_value(field: &str, value: Value) -> Value {
if !matches!(field, "swing_vertical" | "swing_horizontal") {
return value;
}
match value {
Value::Bool(value) => json!(u8::from(value)),
Value::String(value) if value.eq_ignore_ascii_case("true") => json!(1),
Value::String(value) if value.eq_ignore_ascii_case("false") => json!(0),
value => value,
}
}
fn flow_device_state_value(device: &Device, field: &str) -> Option<Value> {
Some(match field {
"enabled" => json!(device.enabled),
@@ -603,8 +615,14 @@ async fn flow_leaf_observation(
"device_state" => {
let id = c.get("device_id").and_then(Value::as_str).unwrap_or("");
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
let actual = override_value.unwrap_or_else(|| devices.iter().find(|device| device.id == id).and_then(|device| flow_device_state_value(device, field)).unwrap_or(Value::Null));
let expected = c.get("value").cloned().unwrap_or(Value::Null);
let actual = flow_louver_compat_value(
field,
override_value.unwrap_or_else(|| devices.iter().find(|device| device.id == id).and_then(|device| flow_device_state_value(device, field)).unwrap_or(Value::Null)),
);
let expected = flow_louver_compat_value(
field,
c.get("value").cloned().unwrap_or(Value::Null),
);
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
}
"zone_state" => {
+14
View File
@@ -392,6 +392,20 @@ pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError>
));
}
}
if let Some(value) = command.swing_vertical {
if value > VERTICAL_SWING_MAX {
return Err(AppError::BadRequest(format!(
"vertical louver position must be between 0 and {VERTICAL_SWING_MAX}"
)));
}
}
if let Some(value) = command.swing_horizontal {
if value > HORIZONTAL_SWING_MAX {
return Err(AppError::BadRequest(format!(
"horizontal louver position must be between 0 and {HORIZONTAL_SWING_MAX}"
)));
}
}
if let Some(value) = &command.mode {
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
return Err(AppError::BadRequest("unsupported HVAC mode".into()));
+2 -2
View File
@@ -708,8 +708,8 @@ mod tests {
));
assert!(!automation_action_conflicts_with_thermostat(
&DeviceCommand {
swing_vertical: Some(true),
swing_horizontal: Some(false),
swing_vertical: Some(1),
swing_horizontal: Some(0),
..Default::default()
}
));
+74 -10
View File
@@ -89,6 +89,41 @@ fn default_temperature_step() -> f64 {
1.0
}
pub const VERTICAL_SWING_MAX: u8 = 11;
pub const HORIZONTAL_SWING_MAX: u8 = 6;
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(untagged)]
enum LouverPositionCompat {
Bool(bool),
Number(u8),
}
fn louver_position_compat(value: LouverPositionCompat) -> u8 {
match value {
LouverPositionCompat::Bool(value) => u8::from(value),
LouverPositionCompat::Number(value) => value,
}
}
fn deserialize_louver_position<'de, D>(deserializer: D) -> Result<u8, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(louver_position_compat(LouverPositionCompat::deserialize(
deserializer,
)?))
}
fn deserialize_optional_louver_position<'de, D>(
deserializer: D,
) -> Result<Option<u8>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<LouverPositionCompat>::deserialize(deserializer)?.map(louver_position_compat))
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum EnergySourcePreference {
@@ -176,10 +211,12 @@ pub struct Device {
pub target_temperature: f64,
#[serde(default = "default_fan")]
pub fan_speed: u8,
#[serde(default)]
pub swing_vertical: bool,
#[serde(default)]
pub swing_horizontal: bool,
/// Raw GREE `SwUpDn` louver position. 0=off/default, 1=full swing, 2..6=fixed positions, 7..11=partial swing ranges.
#[serde(default, deserialize_with = "deserialize_louver_position")]
pub swing_vertical: u8,
/// Raw GREE `SwingLfRig` louver position. 0=off/default, 1=full swing, 2..6=fixed positions.
#[serde(default, deserialize_with = "deserialize_louver_position")]
pub swing_horizontal: u8,
#[serde(default)]
pub quiet: bool,
/// Raw non-zero value last reported by the unit for the GREE `Quiet` property.
@@ -290,8 +327,8 @@ impl Device {
mode: "cool".into(),
target_temperature: 23.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
swing_vertical: 0,
swing_horizontal: 0,
quiet: false,
quiet_wire_value: None,
turbo: false,
@@ -389,8 +426,12 @@ pub struct DeviceCommand {
pub mode: Option<String>,
pub target_temperature: Option<f64>,
pub fan_speed: Option<u8>,
pub swing_vertical: Option<bool>,
pub swing_horizontal: Option<bool>,
/// Raw GREE `SwUpDn` value. Legacy boolean payloads remain accepted (`false`=0, `true`=1).
#[serde(default, deserialize_with = "deserialize_optional_louver_position")]
pub swing_vertical: Option<u8>,
/// Raw GREE `SwingLfRig` value. Legacy boolean payloads remain accepted (`false`=0, `true`=1).
#[serde(default, deserialize_with = "deserialize_optional_louver_position")]
pub swing_horizontal: Option<u8>,
pub quiet: Option<bool>,
pub turbo: Option<bool>,
pub light: Option<bool>,
@@ -476,10 +517,10 @@ impl DeviceCommand {
device.fan_speed = v.min(5);
}
if let Some(v) = self.swing_vertical {
device.swing_vertical = v;
device.swing_vertical = v.min(VERTICAL_SWING_MAX);
}
if let Some(v) = self.swing_horizontal {
device.swing_horizontal = v;
device.swing_horizontal = v.min(HORIZONTAL_SWING_MAX);
}
if let Some(v) = self.quiet {
device.quiet = v;
@@ -544,4 +585,27 @@ mod connection_type_tests {
let migrated: Device = serde_json::from_value(value).expect("deserialize legacy device");
assert_eq!(migrated.connection_type, ConnectionType::Local);
}
#[test]
fn legacy_boolean_swing_values_migrate_to_positions() {
let device = Device::simulated_default();
let mut value = serde_json::to_value(device).expect("serialize device");
let object = value.as_object_mut().expect("device object");
object.insert("swing_vertical".into(), serde_json::Value::Bool(true));
object.insert("swing_horizontal".into(), serde_json::Value::Bool(false));
let migrated: Device = serde_json::from_value(value).expect("deserialize legacy device");
assert_eq!(migrated.swing_vertical, 1);
assert_eq!(migrated.swing_horizontal, 0);
}
#[test]
fn legacy_boolean_swing_command_is_accepted() {
let command: DeviceCommand = serde_json::from_value(serde_json::json!({
"swing_vertical": true,
"swing_horizontal": false
}))
.expect("deserialize legacy command");
assert_eq!(command.swing_vertical, Some(1));
assert_eq!(command.swing_horizontal, Some(0));
}
}
+4 -1
View File
@@ -1,7 +1,10 @@
use super::crypto::{
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
};
use crate::models::{ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand};
use crate::models::{
ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, HORIZONTAL_SWING_MAX,
VERTICAL_SWING_MAX,
};
use anyhow::{anyhow, bail, Context, Result};
use chrono::Utc;
use serde_json::{json, Value};
+2 -2
View File
@@ -191,11 +191,11 @@ impl GreeClient {
}
if let Some(v) = command.swing_vertical {
opt.push("SwUpDn");
values.push(json!(if v { 1 } else { 0 }));
values.push(json!(v.min(VERTICAL_SWING_MAX)));
}
if let Some(v) = command.swing_horizontal {
opt.push("SwingLfRig");
values.push(json!(if v { 1 } else { 0 }));
values.push(json!(v.min(HORIZONTAL_SWING_MAX)));
}
if let Some(v) = command.quiet {
opt.push("Quiet");
+2 -2
View File
@@ -201,8 +201,8 @@ impl GreeClient {
mode: "cool".into(),
target_temperature: 24.0,
fan_speed: 0,
swing_vertical: false,
swing_horizontal: false,
swing_vertical: 0,
swing_horizontal: 0,
quiet: false,
quiet_wire_value: None,
turbo: false,
+14 -2
View File
@@ -193,8 +193,20 @@ impl GreeClient {
}
next.fan_speed = raw as u8;
}
"SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0,
"SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0,
"SwUpDn" => {
let raw = status_i64(name, value)?;
if !(0..=i64::from(VERTICAL_SWING_MAX)).contains(&raw) {
bail!("invalid GREE vertical louver value for {name}: {raw}")
}
next.swing_vertical = raw as u8;
}
"SwingLfRig" => {
let raw = status_i64(name, value)?;
if !(0..=i64::from(HORIZONTAL_SWING_MAX)).contains(&raw) {
bail!("invalid GREE horizontal louver value for {name}: {raw}")
}
next.swing_horizontal = raw as u8;
}
"Quiet" => {
let raw = status_i64(name, value)?;
if raw < 0 {
+11 -6
View File
@@ -1,6 +1,7 @@
use crate::{
models::{
ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings,
HORIZONTAL_SWING_MAX, VERTICAL_SWING_MAX,
},
protocol::{
crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2},
@@ -603,9 +604,9 @@ impl GreeCloudProvider {
if let Some(value) = command.swing_vertical {
simple.push((
"SwUpDn",
json!(if value { 1 } else { 0 }),
json!(value.min(VERTICAL_SWING_MAX)),
DeviceCommand {
swing_vertical: Some(value),
swing_vertical: Some(value.min(VERTICAL_SWING_MAX)),
..Default::default()
},
));
@@ -613,9 +614,9 @@ impl GreeCloudProvider {
if let Some(value) = command.swing_horizontal {
simple.push((
"SwingLfRig",
json!(if value { 1 } else { 0 }),
json!(value.min(HORIZONTAL_SWING_MAX)),
DeviceCommand {
swing_horizontal: Some(value),
swing_horizontal: Some(value.min(HORIZONTAL_SWING_MAX)),
..Default::default()
},
));
@@ -1675,10 +1676,14 @@ pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap<String, Valu
device.fan_speed = value.clamp(0, 5) as u8;
}
if let Some(value) = props.get("SwUpDn").and_then(value_i64) {
device.swing_vertical = value != 0;
if (0..=i64::from(VERTICAL_SWING_MAX)).contains(&value) {
device.swing_vertical = value as u8;
}
}
if let Some(value) = props.get("SwingLfRig").and_then(value_i64) {
device.swing_horizontal = value != 0;
if (0..=i64::from(HORIZONTAL_SWING_MAX)).contains(&value) {
device.swing_horizontal = value as u8;
}
}
if let Some(value) = props.get("Quiet").and_then(value_i64) {
device.quiet = value != 0;