use std::collections::BTreeMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; fn default_true() -> bool { true } fn default_port() -> u16 { 7000 } fn default_protocol() -> u8 { 0 } fn default_mode() -> String { "cool".into() } fn default_fan() -> u8 { 0 } fn default_target() -> f64 { 24.0 } fn default_hysteresis() -> f64 { 0.6 } fn default_external_sensor_weight() -> f64 { 0.4 } fn default_max_sensor_difference() -> f64 { 3.0 } fn default_control_temperature_source() -> String { "device".into() } fn default_min_cycle() -> u64 { 180 } fn default_sensor_stale_after() -> u64 { 300 } fn default_cooldown() -> u64 { 300 } fn default_house_mode() -> String { "cool".into() } fn default_control_strategy() -> String { "setpoint".into() } fn default_standby_offset() -> f64 { 2.0 } fn default_min_adjust() -> u64 { 120 } fn default_schedule_preset() -> String { "custom".into() } fn default_active_preset() -> String { "comfort".into() } fn default_cool_comfort() -> f64 { 23.0 } fn default_cool_sleep() -> f64 { 24.5 } fn default_cool_away() -> f64 { 27.0 } fn default_heat_comfort() -> f64 { 21.0 } fn default_heat_sleep() -> f64 { 19.0 } fn default_heat_away() -> f64 { 17.0 } fn default_history_retention_days() -> u32 { 30 } fn default_event_log_retention_days() -> u32 { 30 } fn default_influx_version() -> String { "2".into() } fn default_influx_database() -> String { "gree_controller".into() } fn default_influx_threshold_days() -> u32 { 30 } fn default_night_start() -> String { "22:00".into() } fn default_night_end() -> String { "06:00".into() } fn default_night_max_fan_speed() -> u8 { 1 } fn default_group_power_enabled() -> bool { true } fn default_temporary_tolerance() -> f64 { 0.3 } fn default_temporary_start_kind() -> String { "now".into() } fn default_temporary_state() -> String { "scheduled".into() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Device { pub id: String, pub mac: String, pub name: String, pub ip: String, #[serde(default = "default_port")] pub port: u16, #[serde(default = "default_protocol")] pub protocol_version: u8, #[serde(default)] pub model: String, #[serde(default)] pub firmware: String, #[serde(default)] pub key: Option, #[serde(default)] pub cid: Option, #[serde(default = "default_true")] pub enabled: bool, #[serde(default)] pub simulated: bool, #[serde(default)] pub power: bool, #[serde(default = "default_mode")] pub mode: String, #[serde(default = "default_target")] pub target_temperature: f64, #[serde(default = "default_fan")] pub fan_speed: u8, #[serde(default)] pub swing_vertical: bool, #[serde(default)] pub swing_horizontal: bool, #[serde(default)] pub quiet: bool, #[serde(default)] 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, #[serde(default)] pub supports_quiet: Option, #[serde(default)] pub supports_turbo: Option, #[serde(default)] pub supports_air: Option, #[serde(default)] pub supports_xfan: Option, #[serde(default)] pub supports_health: Option, #[serde(default)] pub supports_sleep: Option, #[serde(default)] pub current_temperature: Option, #[serde(default)] pub outdoor_temperature: Option, /// Some GREE firmware reports TemSen/OutEnvTem with a +40 C wire offset. #[serde(default)] pub temperature_sensor_offset: Option, #[serde(default)] pub online: bool, /// Round-trip time of the latest successful controller communication. #[serde(default)] pub response_time_ms: Option, #[serde(default)] pub last_seen: Option>, #[serde(default)] pub last_error: Option, #[serde(default)] pub communication_failures: u8, pub created_at: DateTime, pub updated_at: DateTime, } impl Device { pub fn simulated_default() -> Self { let now = Utc::now(); Self { id: "sim-salon".into(), mac: "SIM000000001".into(), name: "Living Room (simulator)".into(), ip: "127.0.0.1".into(), port: 7000, protocol_version: 1, model: "GREE-SIM".into(), firmware: "sim-1.0".into(), key: None, cid: Some("gree-controller".into()), enabled: true, simulated: true, power: false, mode: "cool".into(), target_temperature: 23.0, fan_speed: 0, swing_vertical: false, swing_horizontal: false, 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), online: true, response_time_ms: Some(0), last_seen: Some(now), last_error: None, communication_failures: 0, created_at: now, updated_at: now, } } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DevicePatch { pub name: Option, pub ip: Option, pub port: Option, pub protocol_version: Option, pub key: Option>, pub enabled: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DeviceCommand { pub power: Option, pub mode: Option, pub target_temperature: Option, pub fan_speed: Option, pub swing_vertical: Option, pub swing_horizontal: Option, pub quiet: Option, pub turbo: Option, pub light: Option, pub air: Option, pub xfan: Option, pub health: Option, pub sleep: Option, } impl DeviceCommand { pub fn is_empty(&self) -> bool { 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. pub fn changed_from(&self, device: &Device) -> Self { Self { power: self.power.filter(|value| *value != device.power), mode: self.mode.as_ref().filter(|value| value.as_str() != device.mode.as_str()).cloned(), target_temperature: self.target_temperature.filter(|value| value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()), fan_speed: self.fan_speed.filter(|value| (*value).min(5) != device.fan_speed), swing_vertical: self.swing_vertical.filter(|value| *value != device.swing_vertical), swing_horizontal: self.swing_horizontal.filter(|value| *value != device.swing_horizontal), 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), } } pub fn apply(&self, device: &mut Device) { if let Some(v) = self.power { device.power = v; } if let Some(v) = &self.mode { device.mode = v.clone(); } if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0).round(); } if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); } if let Some(v) = self.swing_vertical { device.swing_vertical = v; } if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; } 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(); } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManualDeviceBaseline { pub power: bool, pub mode: String, pub target_temperature: f64, pub fan_speed: u8, pub quiet: bool, pub sleep: bool, } impl From<&Device> for ManualDeviceBaseline { fn from(device: &Device) -> Self { Self { power: device.power, mode: device.mode.clone(), target_temperature: device.target_temperature, fan_speed: device.fan_speed, quiet: device.quiet, sleep: device.sleep, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemporaryQuickThermostat { /// now | delay | at. `started_at` is the effective start instant and may be in the future. #[serde(default = "default_temporary_start_kind")] pub start_kind: String, /// duration | until | temperature_reached | temperature_stable | schedule_boundary pub finish_kind: String, /// Effective start instant. Future values mean the temporary thermostat is scheduled /// but does not yet own the zone or block normal schedules/automations. pub started_at: DateTime, /// Set when the controller actually activates ownership. Kept separate from started_at /// so a delayed session can survive restarts without being mistaken for an active one. #[serde(default)] pub activated_at: Option>, /// Explicit lifecycle state: scheduled | waiting_master | paused_manual | active. #[serde(default = "default_temporary_state")] pub state: String, /// Heat/cool mode captured when ownership really starts. It keeps a temporary session /// independent from later whole-house mode changes until hand-back. #[serde(default)] pub active_mode: Option, /// Zone automation enabled-state from immediately before the actual takeover. A delayed /// Quick Thermostat may run even when normal automation was disabled, then restore it. #[serde(default)] pub restore_zone_enabled: Option, /// Ordinary local Quick Thermostat state hidden underneath this higher-priority session. /// It is captured at actual takeover and restored on hand-back. #[serde(default)] pub restore_local_thermostat_power: Option, #[serde(default)] pub restore_local_thermostat_resume_at: Option>, #[serde(default)] pub restore_local_thermostat_zone_enabled: Option, #[serde(default)] pub restore_manual_preset: Option, #[serde(default)] pub restore_manual_setpoint: Option, #[serde(default)] pub restore_manual_override_until: Option>, /// Hard end for duration/until/schedule-boundary modes. #[serde(default)] pub expires_at: Option>, /// Relative durations are retained so delayed/manual-waiting sessions start their clocks /// when ownership actually begins rather than at the originally requested wall-clock time. #[serde(default)] pub duration_seconds: Option, #[serde(default)] pub safety_duration_seconds: Option, /// Temperature condition used by reached/stable modes. #[serde(default)] pub temperature_target: Option, /// within | at_or_below | at_or_above #[serde(default)] pub temperature_operator: Option, #[serde(default = "default_temporary_tolerance")] pub tolerance_c: f64, /// Continuous in-condition time required by temperature_stable. #[serde(default)] pub hold_seconds: u64, /// Set only while fresh consecutive room samples continuously satisfy the condition. #[serde(default)] pub condition_started_at: Option>, /// Timestamp of the last fresh sensor sample used by the condition evaluator. This /// prevents cached samples and controller downtime from counting as continuous hold time. #[serde(default)] pub condition_last_observed_at: Option>, /// Start of a higher-priority direct/manual pause. Active deadlines are shifted by this /// pause when ownership returns so hidden manual time is never consumed by the session. #[serde(default)] pub paused_at: Option>, /// Group/house climate changes received while this session owns the zone. They are /// applied only after hand-back instead of partially overwriting the active session. #[serde(default)] pub deferred_mode: Option, #[serde(default)] pub deferred_preset: Option, /// Optional fail-safe for temperature-based modes. #[serde(default)] pub safety_expires_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TemporaryQuickThermostatRequest { #[serde(default = "default_temporary_start_kind")] pub start_kind: String, #[serde(default)] pub start_delay_minutes: Option, #[serde(default)] pub start_at: Option>, pub finish_kind: String, #[serde(default)] pub duration_minutes: Option, #[serde(default)] pub until: Option>, #[serde(default)] pub target_temperature: Option, #[serde(default)] pub temperature_operator: Option, #[serde(default)] pub tolerance_c: Option, #[serde(default)] pub hold_minutes: Option, #[serde(default)] pub max_duration_minutes: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Zone { pub id: String, pub name: String, pub device_id: String, #[serde(default = "default_true")] pub enabled: bool, #[serde(default = "default_mode")] pub mode: String, /// When true, the zone follows the global house heating/cooling mode. #[serde(default = "default_true")] pub inherit_house_mode: bool, /// Manual/custom target retained for quick thermostat overrides. #[serde(default = "default_target")] pub setpoint: f64, /// 0 means a zone created before smart profiles; its legacy setpoint remains the comfort target until edited. #[serde(default)] pub profile_version: u8, #[serde(default = "default_cool_comfort")] pub cool_comfort_setpoint: f64, #[serde(default = "default_cool_sleep")] pub cool_sleep_setpoint: f64, #[serde(default = "default_cool_away")] pub cool_away_setpoint: f64, #[serde(default = "default_heat_comfort")] pub heat_comfort_setpoint: f64, #[serde(default = "default_heat_sleep")] pub heat_sleep_setpoint: f64, #[serde(default = "default_heat_away")] pub heat_away_setpoint: f64, #[serde(default = "default_hysteresis")] pub hysteresis: f64, #[serde(default = "default_min_cycle")] pub min_on_seconds: u64, #[serde(default = "default_min_cycle")] pub min_off_seconds: u64, /// Minimum interval between automatic setpoint/fan adjustments. #[serde(default = "default_min_adjust")] pub min_adjust_seconds: u64, /// Difference applied to the AC setpoint while the room is satisfied. #[serde(default = "default_standby_offset")] pub standby_offset_c: f64, /// Let the controller adjust fan speed based on demand and outdoor conditions. #[serde(default = "default_true")] pub smart_fan: bool, #[serde(default = "default_sensor_source")] pub sensor_source: String, #[serde(default)] pub ha_entity_id: Option, /// Weight of the optional room sensor when sensor_source is `combined`. #[serde(default = "default_external_sensor_weight")] pub external_sensor_weight: f64, /// If GREE and external sensor differ more than this, the controller falls back to GREE. #[serde(default = "default_max_sensor_difference")] pub max_sensor_difference: f64, /// Maximum accepted age of a Home Assistant room sensor sample. #[serde(default = "default_sensor_stale_after")] pub sensor_stale_after_seconds: u64, /// Temperature reported by the GREE indoor sensor during the last zone cycle. #[serde(default)] pub device_temperature: Option, /// Temperature reported by the per-zone external Home Assistant sensor. #[serde(default)] pub external_temperature: Option, /// Temperature actually used by the zone controller. #[serde(default)] pub current_temperature: Option, /// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`. #[serde(default = "default_control_temperature_source")] pub control_temperature_source: String, /// Effective profile currently used by the zone: comfort/sleep/away/custom. #[serde(default = "default_active_preset")] pub active_preset: String, /// Optional user preset override. Cleared automatically at the next schedule boundary. #[serde(default)] pub manual_preset: Option, /// Optional quick-thermostat setpoint override. It does not change the active preset. #[serde(default)] pub manual_setpoint: Option, #[serde(default)] pub manual_override_until: Option>, /// Local quick-thermostat power override. None follows group/global power gates; /// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off. #[serde(default)] pub local_thermostat_power: Option, /// Automatic hand-back deadline after the local quick thermostat is switched OFF. /// When reached, local ownership and quick profile/setpoint overrides are cleared and /// the current group/schedule state is evaluated again. #[serde(default)] pub local_thermostat_resume_at: Option>, /// Zone automation enabled-state from before an ordinary local Quick Thermostat takeover. /// Kept separate from the temporary-session restore state. #[serde(default)] pub local_thermostat_restore_zone_enabled: Option, /// Separate, user-defined temporary Quick Thermostat session. This is intentionally /// independent from local_thermostat_resume_at, which belongs to the local-OFF /// hand-back mechanism. #[serde(default)] pub temporary_quick_thermostat: Option, /// True when the physical unit was changed outside the thermostat engine (for example by IR remote). /// While active, normal zone/group/schedule automation observes the unit but does not overwrite it. #[serde(default)] pub device_manual_override: bool, #[serde(default)] pub device_manual_override_since: Option>, #[serde(default)] pub device_manual_override_until: Option>, /// Climate-relevant fields changed during the current external/manual takeover. #[serde(default)] pub device_manual_override_fields: Vec, /// Controller-observed climate state immediately before the takeover started. /// It lets us drop a stale "resume automation" prompt when the user restores that state. #[serde(default)] pub device_manual_override_baseline: Option, /// Monotonic configuration/control revision used for optimistic concurrency. #[serde(default)] pub revision: u64, /// Normalized control ownership exposed consistently to API/Web/Home Assistant. #[serde(default)] pub control_owner: String, #[serde(default)] pub control_source: String, #[serde(default)] pub control_since: Option>, #[serde(default)] pub control_resume_at: Option>, #[serde(default)] pub control_reason: String, /// Last physical power/mode transition timestamps used by compressor lockout. #[serde(default)] pub last_power_change_at: Option>, #[serde(default)] pub last_mode_change_at: Option>, #[serde(default)] pub lockout_until: Option>, #[serde(default)] pub lockout_reason: Option, #[serde(default)] pub effective_mode: String, #[serde(default)] pub effective_setpoint: Option, #[serde(default)] pub device_setpoint: Option, #[serde(default)] pub demand: bool, #[serde(default)] pub demand_since: Option>, #[serde(default)] pub target_alerted_at: Option>, #[serde(default)] pub last_action_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } fn default_sensor_source() -> String { "device".into() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClimateGroup { pub id: String, pub name: String, #[serde(default)] pub zone_ids: Vec, #[serde(default = "default_group_power_enabled")] pub power_enabled: bool, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct GroupControlPatch { #[serde(default)] pub power: Option, /// house follows the global house mode; cool/heat set an explicit mode on every member zone. #[serde(default)] pub mode: Option, /// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone. #[serde(default)] pub preset: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ZoneControlPatch { #[serde(default)] pub setpoint: Option, /// Local quick-thermostat power. This is thermostat ownership, not direct device/pilot control. #[serde(default)] pub power: Option, #[serde(default)] pub mode: Option, #[serde(default)] pub enabled: Option, /// `auto` clears the override; comfort/sleep/away/custom create a temporary override. #[serde(default)] pub preset: Option, #[serde(default)] pub clear_override: Option, /// Explicitly hand control of a manually overridden physical unit back to the thermostat engine. #[serde(default)] pub clear_device_manual_override: Option, /// Return a locally forced quick thermostat to normal group/schedule ownership. #[serde(default)] pub clear_local_thermostat_override: Option, /// Start or replace a temporary Quick Thermostat session. #[serde(default)] pub temporary_quick_thermostat: Option, /// Stop only the temporary Quick Thermostat session and return to automation. #[serde(default)] pub clear_temporary_quick_thermostat: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Schedule { pub id: String, pub zone_id: String, pub name: String, #[serde(default = "default_true")] pub enabled: bool, /// ISO weekday numbers, Monday=1, Sunday=7. pub weekdays: Vec, /// Local time HH:MM. pub start_time: String, /// Local time HH:MM. Ranges crossing midnight are supported. pub end_time: String, /// comfort/sleep/away/custom. Non-custom profiles resolve their target from the zone. #[serde(default = "default_schedule_preset")] pub preset: String, pub setpoint: f64, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Automation { pub id: String, pub name: String, #[serde(default = "default_true")] pub enabled: bool, /// temperature_above, temperature_below, time pub trigger_kind: String, #[serde(default)] pub trigger_device_id: Option, #[serde(default)] pub threshold: Option, #[serde(default)] pub at_time: Option, /// Legacy/direct-device target. Empty when this automation targets a group. #[serde(default)] pub action_device_id: String, /// Optional climate group target. When set, the action is applied to every member zone/device. #[serde(default)] pub action_group_id: Option, /// Optional thermostat preset used only for group actions. #[serde(default)] pub action_preset: Option, #[serde(default)] pub action: DeviceCommand, #[serde(default = "default_cooldown")] pub cooldown_seconds: u64, #[serde(default)] pub last_fired_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Reading { pub id: i64, pub device_id: String, pub timestamp: DateTime, pub indoor_temperature: Option, pub outdoor_temperature: Option, pub target_temperature: f64, pub power: bool, pub source: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ZoneReading { pub id: i64, pub zone_id: String, pub device_id: String, pub timestamp: DateTime, pub gree_temperature: Option, pub external_temperature: Option, pub control_temperature: Option, pub target_temperature: Option, pub device_setpoint: Option, pub outdoor_temperature: Option, pub power: bool, pub mode: String, pub fan_speed: u8, pub demand: bool, pub control_source: String, pub active_preset: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HaReading { pub id: i64, pub entity_id: String, pub zone_id: Option, pub kind: String, pub timestamp: DateTime, pub temperature: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EventLog { pub id: i64, pub timestamp: DateTime, pub level: String, pub kind: String, pub message: String, pub metadata: Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HomeAssistantSettings { #[serde(default)] pub url: String, #[serde(default)] pub token: String, #[serde(default)] pub default_entity_id: String, /// Optional outdoor temperature sensor used only as an assist signal. #[serde(default)] pub outdoor_entity_id: String, /// Maximum accepted age of Home Assistant sensor samples. #[serde(default = "default_sensor_stale_after")] pub sensor_stale_after_seconds: u64, /// Accept self-signed/expired certificates for local Home Assistant HTTPS. #[serde(default)] pub allow_invalid_tls: bool, /// Friendly labels used only by the controller UI/charts; entity_id remains the storage key. #[serde(default)] pub sensor_aliases: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InfluxDbSettings { #[serde(default)] pub enabled: bool, /// InfluxDB API generation: `1` or `2`. #[serde(default = "default_influx_version")] pub version: String, #[serde(default)] pub url: String, /// InfluxDB 1.x database name. #[serde(default = "default_influx_database")] pub database: String, #[serde(default)] pub username: String, #[serde(default)] pub password: String, /// InfluxDB 2.x organization. #[serde(default)] pub org: String, /// InfluxDB 2.x bucket. #[serde(default = "default_influx_database")] pub bucket: String, #[serde(default)] pub token: String, /// Queries older than this age are read from InfluxDB when it is enabled. #[serde(default = "default_influx_threshold_days")] pub history_threshold_days: u32, } impl Default for InfluxDbSettings { fn default() -> Self { Self { enabled: false, version: default_influx_version(), url: String::new(), database: default_influx_database(), username: String::new(), password: String::new(), org: String::new(), bucket: default_influx_database(), token: String::new(), history_threshold_days: default_influx_threshold_days(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationAlertTypes { /// Home Assistant sensor exceeded the configured freshness window. #[serde(default = "default_true")] pub stale_sensor: bool, /// Other Home Assistant sensor errors (missing/unavailable/invalid value). #[serde(default = "default_true")] pub sensor_errors: bool, /// Device connectivity, polling and communication problems. #[serde(default = "default_true")] pub communication: bool, /// Zone failed to reach its target within the configured timeout. #[serde(default = "default_true")] pub target_timeout: bool, /// Automation execution errors and conflicts. #[serde(default = "default_true")] pub automation: bool, /// Thermostat/group/device control failures and sensor discrepancies. #[serde(default = "default_true")] pub control_errors: bool, /// Informational state changes sent when notification mode is "important". #[serde(default = "default_true")] pub important_events: bool, /// Any warning/error not matched by one of the categories above. #[serde(default = "default_true")] pub other: bool, } impl Default for NotificationAlertTypes { fn default() -> Self { Self { stale_sensor: true, sensor_errors: true, communication: true, target_timeout: true, automation: true, control_errors: true, important_events: true, other: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationSettings { #[serde(default)] pub enabled: bool, /// problems = warnings/errors/anomalies, important = problems plus important state changes. #[serde(default = "default_notification_mode")] pub mode: String, /// pushover, slack, discord #[serde(default = "default_notification_provider")] pub provider: String, #[serde(default)] pub pushover_app_token: String, #[serde(default)] pub pushover_user_key: String, #[serde(default)] pub slack_webhook_url: String, #[serde(default)] pub discord_webhook_url: String, #[serde(default = "default_notification_cooldown")] pub cooldown_seconds: u64, #[serde(default = "default_notification_failure_threshold")] pub communication_failure_threshold: u32, #[serde(default = "default_notification_target_timeout")] pub target_timeout_minutes: u32, /// Fine-grained selection of which alert categories may be delivered. #[serde(default)] pub alert_types: NotificationAlertTypes, } fn default_notification_mode() -> String { "problems".into() } fn default_notification_provider() -> String { "pushover".into() } fn default_notification_cooldown() -> u64 { 300 } fn default_notification_failure_threshold() -> u32 { 3 } fn default_notification_target_timeout() -> u32 { 60 } impl Default for NotificationSettings { fn default() -> Self { Self { enabled: false, mode: default_notification_mode(), provider: default_notification_provider(), pushover_app_token: String::new(), pushover_user_key: String::new(), slack_webhook_url: String::new(), discord_webhook_url: String::new(), cooldown_seconds: default_notification_cooldown(), communication_failure_threshold: default_notification_failure_threshold(), target_timeout_minutes: default_notification_target_timeout(), alert_types: NotificationAlertTypes::default(), } } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DebugSettings { #[serde(default)] pub overlay_enabled: bool, #[serde(default)] pub gree_frames: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NightModeSettings { #[serde(default)] pub enabled: bool, #[serde(default = "default_night_start")] pub start_time: String, #[serde(default = "default_night_end")] pub end_time: String, /// Maximum fan speed used by thermostat control during night hours (1=Low..5=High). #[serde(default = "default_night_max_fan_speed")] pub max_fan_speed: u8, /// 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 { fn default() -> Self { Self { enabled: false, start_time: default_night_start(), end_time: default_night_end(), max_fan_speed: default_night_max_fan_speed(), force_quiet: true, use_native_sleep: true, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigurationExport { pub format_version: u32, pub exported_at: DateTime, pub settings: RuntimeSettings, pub devices: Vec, pub zones: Vec, #[serde(default)] pub groups: Vec, pub schedules: Vec, pub automations: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ControlPlanEvent { pub at: DateTime, pub kind: String, pub label: String, pub preset: Option, pub target_temperature: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ZoneControlPlan { pub zone_id: String, pub zone_name: String, pub device_id: String, pub device_name: String, /// Configured per-zone enable switch. Group/master gates are reported separately. pub enabled: bool, /// True when the configured zone is not currently blocked by a disabled climate group. pub effective_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, /// Profile resolved from a manual override or the active schedule. pub preset: String, /// Explicit per-zone profile override; None means Auto schedule. pub preset_override: Option, pub current_temperature: Option, pub target_temperature: Option, pub device_setpoint: Option, pub desired_power: bool, pub desired_mode: String, pub actual_power: Option, pub actual_mode: Option, pub actual_setpoint: Option, pub demand: bool, pub control_source: String, pub manual_override_until: Option>, pub local_thermostat_power: Option, pub local_thermostat_resume_at: Option>, pub device_manual_override: bool, pub device_manual_override_until: Option>, pub control_owner: String, pub control_command_source: String, pub control_since: Option>, pub resume_at: Option>, pub control_reason: String, pub blocked_reason: Option, pub lockout_until: Option>, pub current_schedule_id: Option, pub current_schedule_name: Option, pub next_events: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AutomationPlanRule { pub id: String, pub name: String, pub enabled: bool, pub trigger_kind: String, pub trigger_device_id: Option, pub trigger_device_name: Option, pub threshold: Option, pub at_time: Option, pub action_device_id: String, pub action_device_name: String, #[serde(default)] pub action_group_id: Option, #[serde(default)] pub action_group_name: Option, #[serde(default)] pub action_preset: Option, pub action: DeviceCommand, pub last_fired_at: Option>, pub next_ready_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ControlPlan { pub generated_at: DateTime, pub house_mode: String, /// Uniform whole-house preset when every zone uses the same override; None for a mixed state. pub house_preset: Option, /// Whole-house master power state. pub house_power: bool, pub outdoor_temperature: Option, pub control_strategy: String, pub night_mode_active: bool, pub night_mode_start: String, pub night_mode_end: String, pub night_mode_max_fan_speed: u8, pub next_events: Vec, pub zones: Vec, pub rules: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeSettings { pub controller_id: String, pub simulator_enabled: bool, pub poll_interval_seconds: u64, pub zone_interval_seconds: u64, pub discovery_timeout_ms: u64, pub discovery_broadcast: String, /// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control. #[serde(default = "default_house_mode")] pub house_mode: String, /// Whole-house master power. False is authoritative and suppresses zone/automation restarts. #[serde(default = "default_true")] pub house_power_enabled: bool, /// `setpoint` keeps units powered and modulates compressor demand by changing target temperature. #[serde(default = "default_control_strategy")] pub control_strategy: String, #[serde(default = "default_true")] pub outdoor_assist_enabled: bool, /// Keep recent metrics locally; older history may live in InfluxDB. #[serde(default = "default_history_retention_days")] pub history_retention_days: u32, #[serde(default = "default_true")] pub history_compaction_enabled: bool, /// Retention window for controller event/debug log rows. #[serde(default = "default_event_log_retention_days")] pub event_log_retention_days: u32, /// Add protocol-specific buzzer suppression fields to command frames. #[serde(default)] pub suppress_device_beep: bool, #[serde(default)] pub influxdb: InfluxDbSettings, #[serde(default)] pub debug: DebugSettings, #[serde(default)] pub night_mode: NightModeSettings, #[serde(default)] pub notifications: NotificationSettings, pub home_assistant: HomeAssistantSettings, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DiscoveryRequest { #[serde(default)] pub timeout_ms: Option, #[serde(default)] pub broadcast: Option, /// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only. #[serde(default)] pub protocol_version: Option, /// Number of scan broadcasts sent during one discovery operation. #[serde(default)] pub passes: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManualDeviceRequest { pub name: String, pub mac: String, pub ip: String, #[serde(default = "default_port")] pub port: u16, #[serde(default = "default_protocol")] pub protocol_version: u8, #[serde(default)] pub key: Option, #[serde(default)] pub simulated: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiTokenInfo { pub id: String, pub name: String, pub token_prefix: String, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiEvent { pub event: String, pub timestamp: DateTime, pub data: Value, }