v0.15.0
This commit is contained in:
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user