This commit is contained in:
Mateusz Gruszczyński
2026-08-24 16:12:31 +02:00
parent 83f744e2cb
commit 66a5d6e5e9
22 changed files with 686 additions and 103 deletions
+68 -4
View File
@@ -283,6 +283,17 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
quiet: false,
turbo: false,
light: true,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: None,
supports_quiet: None,
supports_turbo: None,
supports_air: None,
supports_xfan: None,
supports_health: None,
supports_sleep: None,
current_temperature: if input.simulated { Some(25.0) } else { None },
outdoor_temperature: None,
temperature_sensor_offset: None,
@@ -308,7 +319,20 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } }
if let Some(v) = patch.ip { v.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
if let Some(v) = patch.port { device.port = v; }
if let Some(v) = patch.protocol_version { let v = v.min(2); if device.protocol_version != v { device.protocol_version = v; device.key = None; } }
if let Some(v) = patch.protocol_version {
let v = v.min(2);
if device.protocol_version != v {
device.protocol_version = v;
device.key = None;
device.supports_light = None;
device.supports_quiet = None;
device.supports_turbo = None;
device.supports_air = None;
device.supports_xfan = None;
device.supports_health = None;
device.supports_sleep = None;
}
}
if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); }
if let Some(v) = patch.enabled { device.enabled = v; }
device.updated_at = Utc::now();
@@ -455,7 +479,9 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
input.validate()?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
let zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
state.db.save_zone(&zone)?;
state.broadcast("zone.created", serde_json::to_value(&zone)?);
Ok((StatusCode::CREATED, Json(zone)))
@@ -478,6 +504,8 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.device_setpoint = existing.device_setpoint;
zone.demand = existing.demand;
zone.last_action_at = existing.last_action_at;
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
Ok(Json(zone))
@@ -1104,6 +1132,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
normalize_sensor_aliases(&mut input);
canonicalize_home_assistant_entities(&mut input);
validate_night_mode(&mut input)?;
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
@@ -1114,6 +1143,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
}
state.db.save_runtime_settings(&input)?;
canonicalize_saved_zone_entities(&state, &input)?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({}));
@@ -1133,6 +1163,37 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
.collect();
}
fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) {
let default_entity = settings.home_assistant.default_entity_id.clone();
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) {
settings.home_assistant.default_entity_id = entity_id;
}
let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone();
if !outdoor_entity.trim().is_empty() {
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) {
settings.home_assistant.outdoor_entity_id = entity_id;
}
}
}
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
let Some(configured) = zone.ha_entity_id.clone() else { return; };
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
}
fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
for mut zone in state.db.list_zones()? {
let previous = zone.ha_entity_id.clone();
canonicalize_zone_ha_entity(&mut zone, settings);
if zone.ha_entity_id != previous {
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
}
Ok(())
}
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
@@ -1172,6 +1233,8 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
normalize_sensor_aliases(&mut export.settings);
canonicalize_home_assistant_entities(&mut export.settings);
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
validate_night_mode(&mut export.settings)?;
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
state.db.replace_configuration(&export)?;
@@ -1247,9 +1310,10 @@ async fn delete_access_token(State(state): State<AppState>, Path(id): Path<Strin
struct HaTestRequest { entity_id: Option<String> }
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, input.entity_id.as_deref())
let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref())
.await.map_err(|e| AppError::Device(e.to_string()))?;
Ok(Json(json!({"ok": true, "temperature_c": temperature})))
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
}
fn public_settings(settings: &RuntimeSettings) -> Value {
+2
View File
@@ -67,6 +67,7 @@ impl Config {
end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()),
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5),
force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true),
use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP").unwrap_or(true),
},
home_assistant: HomeAssistantSettings {
url: env::var("HA_URL").unwrap_or_default(),
@@ -98,6 +99,7 @@ impl Config {
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } }
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { settings.night_mode.use_native_sleep = value; }
let influx_env_present = [
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
+85 -15
View File
@@ -154,6 +154,8 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
}
}
}
if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); }
if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); }
applied_command.apply(&mut device);
device.online = true;
device.communication_failures = 0;
@@ -309,18 +311,22 @@ async fn control_zones(state: &AppState) -> Result<()> {
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
// it only makes the active setpoint/fan a little more assertive in extreme weather.
let outdoor_temperature = if !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
match home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
Some(settings.home_assistant.outdoor_entity_id.trim()),
).await {
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
let device_snapshot = state.db.list_devices()?;
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
let resolved_outdoor = if configured_outdoor.is_empty() {
None
} else {
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
};
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id)).await {
Ok(value) => {
record_ha_history(
state,
settings.home_assistant.outdoor_entity_id.trim(),
entity_id,
None,
"outdoor",
value,
@@ -329,13 +335,14 @@ async fn control_zones(state: &AppState) -> Result<()> {
Some(value)
}
Err(err) => {
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
None
}
}
} else {
None
};
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
{
let mut current = state.outdoor_temperature.write().await;
if *current != outdoor_temperature {
@@ -372,16 +379,21 @@ async fn control_zones(state: &AppState) -> Result<()> {
let previous_source = zone.control_temperature_source.clone();
let device_temperature = device.current_temperature;
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await {
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
match home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity.as_deref()).await {
Ok(value) => {
if let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) {
if let Some(entity_id) = resolved_entity.as_deref() {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
}
Some(value)
}
Err(err) => {
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()}));
state.log("warn", "ha.sensor_error", &err.to_string(), json!({
"zone_id": zone.id,
"configured_entity_id": zone.ha_entity_id.as_deref(),
"resolved_entity_id": resolved_entity,
}));
}
None
}
@@ -497,12 +509,20 @@ async fn control_zones(state: &AppState) -> Result<()> {
night_active,
settings.night_mode.force_quiet,
);
let desired_sleep = native_sleep_command(
settings.night_mode.enabled,
night_active,
settings.night_mode.use_native_sleep,
device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id),
device.sleep,
);
let needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false)
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false);
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
let urgent_mode_change = !device.power || device.mode != effective_mode;
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
@@ -512,6 +532,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
target_temperature: Some(desired_device_target),
fan_speed: desired_fan,
quiet: desired_quiet,
sleep: desired_sleep,
..Default::default()
};
match send_command(state, &zone.device_id, command).await {
@@ -527,6 +548,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
"outdoor_temperature": outdoor_temperature,
"fan_speed": updated_device.fan_speed,
"quiet": updated_device.quiet,
"sleep": updated_device.sleep,
"night_mode": night_active,
}));
}
@@ -633,6 +655,23 @@ 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)
.filter_map(|device| device.outdoor_temperature)
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
.collect();
if values.is_empty() { return None; }
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let middle = values.len() / 2;
let value = if values.len() % 2 == 0 {
(values[middle - 1] + values[middle]) / 2.0
} else {
values[middle]
};
Some((value * 10.0).round() / 10.0)
}
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
match zone.sensor_source.as_str() {
"home_assistant" => match (external_temperature, device_temperature) {
@@ -707,6 +746,19 @@ fn smart_quiet_command(
None
}
fn native_sleep_command(
night_enabled: bool,
night_active: bool,
use_native_sleep: bool,
sleep_supported: bool,
device_sleep: bool,
) -> Option<bool> {
if !night_enabled || !use_native_sleep || !sleep_supported { return None; }
if night_active { return Some(true); }
if device_sleep { return Some(false); }
None
}
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
let max_fan = max_fan.clamp(1, 5);
if requested == 0 { 1 } else { requested.min(max_fan) }
@@ -854,6 +906,8 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
enabled: zone.enabled,
mode: effective_mode.to_string(),
configured_mode: zone.mode.clone(),
inherit_house_mode: zone.inherit_house_mode,
preset: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
current_temperature: zone.current_temperature,
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
@@ -1156,7 +1210,7 @@ mod tests {
#[test]
fn night_mode_handles_midnight_and_limits_auto_fan() {
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true };
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true };
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
@@ -1164,6 +1218,22 @@ mod tests {
assert_eq!(night_limited_fan_speed(3, 1), 1);
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
assert_eq!(native_sleep_command(true, true, true, true, false), Some(true));
assert_eq!(native_sleep_command(true, false, true, true, true), Some(false));
assert_eq!(native_sleep_command(true, true, true, false, false), None);
}
#[test]
fn gree_outdoor_fallback_uses_median_of_online_units() {
let mut a = Device::simulated_default();
a.outdoor_temperature = Some(10.0);
let mut b = Device::simulated_default();
b.id = "sim-b".into();
b.outdoor_temperature = Some(12.0);
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));
}
#[test]
+48 -3
View File
@@ -17,6 +17,25 @@ fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSett
.context("cannot build Home Assistant HTTPS client")
}
pub fn resolve_entity_id(settings: &HomeAssistantSettings, entity_override: Option<&str>) -> Option<String> {
let requested = entity_override.filter(|value| !value.trim().is_empty())
.map(str::trim)
.unwrap_or_else(|| settings.default_entity_id.trim());
if requested.is_empty() { return None; }
// Aliases are presentation-only. Accepting an alias here is a defensive
// compatibility path for settings saved by older UI revisions or manual edits;
// the actual Home Assistant request always uses the original entity_id key.
if settings.sensor_aliases.contains_key(requested) {
return Some(requested.to_string());
}
if let Some((entity_id, _)) = settings.sensor_aliases.iter()
.find(|(_, alias)| alias.trim().eq_ignore_ascii_case(requested)) {
return Some(entity_id.clone());
}
Some(requested.to_string())
}
pub async fn read_temperature(
default_client: &reqwest::Client,
settings: &HomeAssistantSettings,
@@ -24,9 +43,8 @@ pub async fn read_temperature(
) -> Result<f64> {
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") }
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") }
let entity = entity_override.filter(|v| !v.trim().is_empty())
.unwrap_or(settings.default_entity_id.trim());
if entity.is_empty() { bail!("Home Assistant entity_id is not configured") }
let entity = resolve_entity_id(settings, entity_override)
.ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?;
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") }
@@ -53,3 +71,30 @@ pub async fn read_temperature(
}
Ok((temperature * 10.0).round() / 10.0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn settings() -> HomeAssistantSettings {
let mut sensor_aliases = BTreeMap::new();
sensor_aliases.insert("sensor.gabinet_temperature".into(), "Gabinet".into());
HomeAssistantSettings {
url: "http://homeassistant.local:8123".into(),
token: "token".into(),
default_entity_id: "sensor.salon_temperature".into(),
outdoor_entity_id: "sensor.zewnatrz_temperature".into(),
allow_invalid_tls: false,
sensor_aliases,
}
}
#[test]
fn aliases_never_replace_real_home_assistant_entity_ids() {
let settings = settings();
assert_eq!(resolve_entity_id(&settings, Some("sensor.gabinet_temperature")).as_deref(), Some("sensor.gabinet_temperature"));
assert_eq!(resolve_entity_id(&settings, Some("Gabinet")).as_deref(), Some("sensor.gabinet_temperature"));
assert_eq!(resolve_entity_id(&settings, None).as_deref(), Some("sensor.salon_temperature"));
}
}
+55
View File
@@ -76,6 +76,29 @@ pub struct Device {
pub turbo: bool,
#[serde(default)]
pub light: bool,
/// Optional GREE feature states. Support is learned from status responses.
#[serde(default)]
pub air: bool,
#[serde(default)]
pub xfan: bool,
#[serde(default)]
pub health: bool,
#[serde(default)]
pub sleep: bool,
#[serde(default)]
pub supports_light: Option<bool>,
#[serde(default)]
pub supports_quiet: Option<bool>,
#[serde(default)]
pub supports_turbo: Option<bool>,
#[serde(default)]
pub supports_air: Option<bool>,
#[serde(default)]
pub supports_xfan: Option<bool>,
#[serde(default)]
pub supports_health: Option<bool>,
#[serde(default)]
pub supports_sleep: Option<bool>,
#[serde(default)]
pub current_temperature: Option<f64>,
#[serde(default)]
@@ -120,6 +143,17 @@ impl Device {
quiet: false,
turbo: false,
light: true,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: Some(true),
supports_quiet: Some(true),
supports_turbo: Some(true),
supports_air: Some(true),
supports_xfan: Some(true),
supports_health: Some(true),
supports_sleep: Some(true),
current_temperature: Some(26.0),
outdoor_temperature: Some(30.0),
temperature_sensor_offset: Some(false),
@@ -154,6 +188,10 @@ pub struct DeviceCommand {
pub quiet: Option<bool>,
pub turbo: Option<bool>,
pub light: Option<bool>,
pub air: Option<bool>,
pub xfan: Option<bool>,
pub health: Option<bool>,
pub sleep: Option<bool>,
}
impl DeviceCommand {
@@ -161,6 +199,7 @@ impl DeviceCommand {
self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none()
&& self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none()
&& self.quiet.is_none() && self.turbo.is_none() && self.light.is_none()
&& self.air.is_none() && self.xfan.is_none() && self.health.is_none() && self.sleep.is_none()
}
/// Return only fields that differ from the last known device state.
@@ -175,6 +214,10 @@ impl DeviceCommand {
quiet: self.quiet.filter(|value| *value != device.quiet),
turbo: self.turbo.filter(|value| *value != device.turbo),
light: self.light.filter(|value| *value != device.light),
air: self.air.filter(|value| *value != device.air),
xfan: self.xfan.filter(|value| *value != device.xfan),
health: self.health.filter(|value| *value != device.health),
sleep: self.sleep.filter(|value| *value != device.sleep),
}
}
@@ -188,6 +231,10 @@ impl DeviceCommand {
if let Some(v) = self.quiet { device.quiet = v; }
if let Some(v) = self.turbo { device.turbo = v; }
if let Some(v) = self.light { device.light = v; }
if let Some(v) = self.air { device.air = v; }
if let Some(v) = self.xfan { device.xfan = v; }
if let Some(v) = self.health { device.health = v; }
if let Some(v) = self.sleep { device.sleep = v; }
device.updated_at = Utc::now();
}
}
@@ -487,6 +534,9 @@ pub struct NightModeSettings {
/// Ask compatible GREE units to keep Quiet enabled during the whole night window.
#[serde(default = "default_true")]
pub force_quiet: bool,
/// Use the unit's native Sleep function during the night window when it is supported.
#[serde(default = "default_true")]
pub use_native_sleep: bool,
}
impl Default for NightModeSettings {
@@ -497,6 +547,7 @@ impl Default for NightModeSettings {
end_time: default_night_end(),
max_fan_speed: default_night_max_fan_speed(),
force_quiet: true,
use_native_sleep: true,
}
}
}
@@ -528,7 +579,11 @@ pub struct ZoneControlPlan {
pub device_id: String,
pub device_name: String,
pub enabled: bool,
/// Effective mode currently used by the controller.
pub mode: String,
/// Configured zone mode before house-mode inheritance is resolved.
pub configured_mode: String,
pub inherit_house_mode: bool,
pub preset: String,
pub current_temperature: Option<f64>,
pub target_temperature: Option<f64>,
+120 -20
View File
@@ -24,6 +24,7 @@ pub struct GreeClient {
debug_gree_frames: Arc<AtomicBool>,
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
quiet_unsupported: Arc<Mutex<HashSet<String>>>,
sleep_unsupported: Arc<Mutex<HashSet<String>>>,
}
impl GreeClient {
@@ -40,6 +41,7 @@ impl GreeClient {
debug_gree_frames,
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
sleep_unsupported: Arc::new(Mutex::new(HashSet::new())),
}
}
@@ -252,6 +254,17 @@ impl GreeClient {
quiet: false,
turbo: false,
light: true,
air: false,
xfan: false,
health: false,
sleep: false,
supports_light: None,
supports_quiet: None,
supports_turbo: None,
supports_air: None,
supports_xfan: None,
supports_health: None,
supports_sleep: None,
current_temperature: None,
outdoor_temperature: None,
temperature_sensor_offset: None,
@@ -357,6 +370,10 @@ impl GreeClient {
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
}
}
// Capability discovery is deliberately lazy. Existing installations start with
// unknown support flags and each optional property is probed at most until a
// definitive success/failure has been persisted with the device state.
self.probe_optional_features(device, &key).await;
device.online = true;
device.communication_failures = 0;
device.last_seen = Some(Utc::now());
@@ -370,6 +387,49 @@ impl GreeClient {
self.request(device, &inner, key, false, device.protocol_version).await
}
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
let probes = [
("Lig", device.supports_light.is_none()),
("Quiet", device.supports_quiet.is_none()),
("Tur", device.supports_turbo.is_none()),
("Air", device.supports_air.is_none()),
("Blo", device.supports_xfan.is_none()),
("Health", device.supports_health.is_none()),
("SwhSlp", device.supports_sleep.is_none()),
];
for (property, needed) in probes {
if !needed { continue; }
match self.status_request(device, key, &[property]).await {
Ok(value) => {
let returned = value.get("cols").and_then(Value::as_array)
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
.unwrap_or(false);
if !returned || self.apply_status(device, &value).is_err() {
Self::set_feature_support(device, property, false);
}
}
Err(err) => {
Self::set_feature_support(device, property, false);
tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available");
}
}
}
}
fn set_feature_support(device: &mut Device, property: &str, supported: bool) {
let value = Some(supported);
match property {
"Lig" => device.supports_light = value,
"Quiet" => device.supports_quiet = value,
"Tur" => device.supports_turbo = value,
"Air" => device.supports_air = value,
"Blo" => device.supports_xfan = value,
"Health" => device.supports_health = value,
"SwhSlp" => device.supports_sleep = value,
_ => {}
}
}
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
let response_cols = response.get("cols").and_then(Value::as_array)
.ok_or_else(|| anyhow!("status response has no cols"))?;
@@ -385,9 +445,13 @@ impl GreeClient {
"WdSpd" => device.fan_speed = value_as_i64(value).clamp(0, 5) as u8,
"SwUpDn" => device.swing_vertical = value_as_i64(value) != 0,
"SwingLfRig" => device.swing_horizontal = value_as_i64(value) != 0,
"Quiet" => device.quiet = value_as_i64(value) != 0,
"Tur" => device.turbo = value_as_i64(value) != 0,
"Lig" => device.light = value_as_i64(value) != 0,
"Quiet" => { device.quiet = value_as_i64(value) != 0; device.supports_quiet = Some(true); },
"Tur" => { device.turbo = value_as_i64(value) != 0; device.supports_turbo = Some(true); },
"Lig" => { device.light = value_as_i64(value) != 0; device.supports_light = Some(true); },
"Air" => { device.air = value_as_i64(value) != 0; device.supports_air = Some(true); },
"Blo" => { device.xfan = value_as_i64(value) != 0; device.supports_xfan = Some(true); },
"Health" => { device.health = value_as_i64(value) != 0; device.supports_health = Some(true); },
"SwhSlp" => { device.sleep = value_as_i64(value) != 0; device.supports_sleep = Some(true); },
"TemSen" => {
let raw = value_as_f64(value);
// The room sensor is a useful discriminator because normal indoor
@@ -420,6 +484,10 @@ impl GreeClient {
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
pub fn sleep_command_supported(&self, device_id: &str) -> bool {
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
}
async fn request_command_with_buzzer_fallback(
&self,
device: &Device,
@@ -455,29 +523,56 @@ impl GreeClient {
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
effective.quiet = None;
}
if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) {
effective.sleep = None;
}
if effective.is_empty() {
return Ok(effective);
}
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
Ok(_) => Ok(effective),
Err(first_err) if effective.quiet.is_some() => {
// Quiet is not implemented by every GREE indoor unit. If a combined thermostat
// command is rejected, retry the same setpoint/fan change without Quiet. Once
// that succeeds, remember the unit so future thermostat frames omit Quiet.
let mut fallback = effective.clone();
fallback.quiet = None;
if fallback.is_empty() { return Err(first_err); }
match self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await {
Ok(_) => {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will use Low fan without Quiet for this device");
Ok(fallback)
Err(first_err) => {
// Quiet and native Sleep are optional GREE features. A unit may report a
// broader status schema than it accepts in command frames, so preserve
// the actual thermostat change and retry without the optional property.
if effective.sleep.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
return Ok(fallback);
}
}
Err(_) => Err(first_err),
}
if effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
return Ok(fallback);
}
}
}
if effective.sleep.is_some() && effective.quiet.is_some() {
let mut fallback = effective.clone();
fallback.sleep = None;
fallback.quiet = None;
if !fallback.is_empty() {
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
return Ok(fallback);
}
}
}
Err(first_err)
}
Err(err) => Err(err),
}
}
@@ -498,6 +593,10 @@ impl GreeClient {
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
if opt.is_empty() { bail!("empty device command") }
if suppress_beep {
opt.push("Buzzer_ON_OFF"); values.push(json!(1));
@@ -731,15 +830,16 @@ mod tests {
use super::*;
#[test]
fn thermostat_standby_setpoint_low_fan_and_quiet_share_one_frame() {
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
let payload = GreeClient::command_payload(&DeviceCommand {
target_temperature: Some(19.0),
fan_speed: Some(1),
quiet: Some(true),
sleep: Some(true),
..DeviceCommand::default()
}, false).expect("thermostat command payload");
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1])));
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])));
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1])));
}
}