v0.5.4
This commit is contained in:
+68
-4
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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])));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user