Files
gree-controller/src/models.rs
T
2026-08-29 22:07:54 +02:00

1087 lines
40 KiB
Rust

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<String>,
#[serde(default)]
pub cid: Option<String>,
#[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<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)]
pub outdoor_temperature: Option<f64>,
/// Some GREE firmware reports TemSen/OutEnvTem with a +40 C wire offset.
#[serde(default)]
pub temperature_sensor_offset: Option<bool>,
#[serde(default)]
pub online: bool,
/// Round-trip time of the latest successful controller communication.
#[serde(default)]
pub response_time_ms: Option<u64>,
#[serde(default)]
pub last_seen: Option<DateTime<Utc>>,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub communication_failures: u8,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<String>,
pub ip: Option<String>,
pub port: Option<u16>,
pub protocol_version: Option<u8>,
pub key: Option<Option<String>>,
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DeviceCommand {
pub power: Option<bool>,
pub mode: Option<String>,
pub target_temperature: Option<f64>,
pub fan_speed: Option<u8>,
pub swing_vertical: Option<bool>,
pub swing_horizontal: Option<bool>,
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 {
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<Utc>,
/// 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<DateTime<Utc>>,
/// 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<String>,
/// 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<bool>,
/// 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<bool>,
#[serde(default)]
pub restore_local_thermostat_resume_at: Option<DateTime<Utc>>,
#[serde(default)]
pub restore_local_thermostat_zone_enabled: Option<bool>,
#[serde(default)]
pub restore_manual_preset: Option<String>,
#[serde(default)]
pub restore_manual_setpoint: Option<f64>,
#[serde(default)]
pub restore_manual_override_until: Option<DateTime<Utc>>,
/// Hard end for duration/until/schedule-boundary modes.
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
/// 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<u64>,
#[serde(default)]
pub safety_duration_seconds: Option<u64>,
/// Temperature condition used by reached/stable modes.
#[serde(default)]
pub temperature_target: Option<f64>,
/// within | at_or_below | at_or_above
#[serde(default)]
pub temperature_operator: Option<String>,
#[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<DateTime<Utc>>,
/// 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<DateTime<Utc>>,
/// 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<DateTime<Utc>>,
/// 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<String>,
#[serde(default)]
pub deferred_preset: Option<String>,
/// Optional fail-safe for temperature-based modes.
#[serde(default)]
pub safety_expires_at: Option<DateTime<Utc>>,
}
#[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<u64>,
#[serde(default)]
pub start_at: Option<DateTime<Utc>>,
pub finish_kind: String,
#[serde(default)]
pub duration_minutes: Option<u64>,
#[serde(default)]
pub until: Option<DateTime<Utc>>,
#[serde(default)]
pub target_temperature: Option<f64>,
#[serde(default)]
pub temperature_operator: Option<String>,
#[serde(default)]
pub tolerance_c: Option<f64>,
#[serde(default)]
pub hold_minutes: Option<u64>,
#[serde(default)]
pub max_duration_minutes: Option<u64>,
}
#[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<String>,
/// 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<f64>,
/// Temperature reported by the per-zone external Home Assistant sensor.
#[serde(default)]
pub external_temperature: Option<f64>,
/// Temperature actually used by the zone controller.
#[serde(default)]
pub current_temperature: Option<f64>,
/// `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<String>,
/// Optional quick-thermostat setpoint override. It does not change the active preset.
#[serde(default)]
pub manual_setpoint: Option<f64>,
#[serde(default)]
pub manual_override_until: Option<DateTime<Utc>>,
/// 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<bool>,
/// 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<DateTime<Utc>>,
/// 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<bool>,
/// 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<TemporaryQuickThermostat>,
/// 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<DateTime<Utc>>,
#[serde(default)]
pub device_manual_override_until: Option<DateTime<Utc>>,
/// Climate-relevant fields changed during the current external/manual takeover.
#[serde(default)]
pub device_manual_override_fields: Vec<String>,
/// 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<ManualDeviceBaseline>,
/// 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<DateTime<Utc>>,
#[serde(default)]
pub control_resume_at: Option<DateTime<Utc>>,
#[serde(default)]
pub control_reason: String,
/// Last physical power/mode transition timestamps used by compressor lockout.
#[serde(default)]
pub last_power_change_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_mode_change_at: Option<DateTime<Utc>>,
#[serde(default)]
pub lockout_until: Option<DateTime<Utc>>,
#[serde(default)]
pub lockout_reason: Option<String>,
#[serde(default)]
pub effective_mode: String,
#[serde(default)]
pub effective_setpoint: Option<f64>,
#[serde(default)]
pub device_setpoint: Option<f64>,
#[serde(default)]
pub demand: bool,
#[serde(default)]
pub demand_since: Option<DateTime<Utc>>,
#[serde(default)]
pub target_alerted_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_action_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
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<String>,
#[serde(default = "default_group_power_enabled")]
pub power_enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GroupControlPatch {
#[serde(default)]
pub power: Option<bool>,
/// house follows the global house mode; cool/heat set an explicit mode on every member zone.
#[serde(default)]
pub mode: Option<String>,
/// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone.
#[serde(default)]
pub preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ZoneControlPatch {
#[serde(default)]
pub setpoint: Option<f64>,
/// Local quick-thermostat power. This is thermostat ownership, not direct device/pilot control.
#[serde(default)]
pub power: Option<bool>,
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub enabled: Option<bool>,
/// `auto` clears the override; comfort/sleep/away/custom create a temporary override.
#[serde(default)]
pub preset: Option<String>,
#[serde(default)]
pub clear_override: Option<bool>,
/// Explicitly hand control of a manually overridden physical unit back to the thermostat engine.
#[serde(default)]
pub clear_device_manual_override: Option<bool>,
/// Return a locally forced quick thermostat to normal group/schedule ownership.
#[serde(default)]
pub clear_local_thermostat_override: Option<bool>,
/// Start or replace a temporary Quick Thermostat session.
#[serde(default)]
pub temporary_quick_thermostat: Option<TemporaryQuickThermostatRequest>,
/// Stop only the temporary Quick Thermostat session and return to automation.
#[serde(default)]
pub clear_temporary_quick_thermostat: Option<bool>,
}
#[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<u32>,
/// 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<Utc>,
pub updated_at: DateTime<Utc>,
}
#[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<String>,
#[serde(default)]
pub threshold: Option<f64>,
#[serde(default)]
pub at_time: Option<String>,
/// 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<String>,
/// Optional thermostat preset used only for group actions.
#[serde(default)]
pub action_preset: Option<String>,
#[serde(default)]
pub action: DeviceCommand,
#[serde(default = "default_cooldown")]
pub cooldown_seconds: u64,
#[serde(default)]
pub last_fired_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reading {
pub id: i64,
pub device_id: String,
pub timestamp: DateTime<Utc>,
pub indoor_temperature: Option<f64>,
pub outdoor_temperature: Option<f64>,
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<Utc>,
pub gree_temperature: Option<f64>,
pub external_temperature: Option<f64>,
pub control_temperature: Option<f64>,
pub target_temperature: Option<f64>,
pub device_setpoint: Option<f64>,
pub outdoor_temperature: Option<f64>,
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<String>,
pub kind: String,
pub timestamp: DateTime<Utc>,
pub temperature: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventLog {
pub id: i64,
pub timestamp: DateTime<Utc>,
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<String, String>,
}
#[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<Utc>,
pub settings: RuntimeSettings,
pub devices: Vec<Device>,
pub zones: Vec<Zone>,
#[serde(default)]
pub groups: Vec<ClimateGroup>,
pub schedules: Vec<Schedule>,
pub automations: Vec<Automation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlPlanEvent {
pub at: DateTime<Utc>,
pub kind: String,
pub label: String,
pub preset: Option<String>,
pub target_temperature: Option<f64>,
}
#[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<String>,
pub current_temperature: Option<f64>,
pub target_temperature: Option<f64>,
pub device_setpoint: Option<f64>,
pub desired_power: bool,
pub desired_mode: String,
pub actual_power: Option<bool>,
pub actual_mode: Option<String>,
pub actual_setpoint: Option<f64>,
pub demand: bool,
pub control_source: String,
pub manual_override_until: Option<DateTime<Utc>>,
pub local_thermostat_power: Option<bool>,
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
pub device_manual_override: bool,
pub device_manual_override_until: Option<DateTime<Utc>>,
pub control_owner: String,
pub control_command_source: String,
pub control_since: Option<DateTime<Utc>>,
pub resume_at: Option<DateTime<Utc>>,
pub control_reason: String,
pub blocked_reason: Option<String>,
pub lockout_until: Option<DateTime<Utc>>,
pub current_schedule_id: Option<String>,
pub current_schedule_name: Option<String>,
pub next_events: Vec<ControlPlanEvent>,
}
#[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<String>,
pub trigger_device_name: Option<String>,
pub threshold: Option<f64>,
pub at_time: Option<String>,
pub action_device_id: String,
pub action_device_name: String,
#[serde(default)]
pub action_group_id: Option<String>,
#[serde(default)]
pub action_group_name: Option<String>,
#[serde(default)]
pub action_preset: Option<String>,
pub action: DeviceCommand,
pub last_fired_at: Option<DateTime<Utc>>,
pub next_ready_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlPlan {
pub generated_at: DateTime<Utc>,
pub house_mode: String,
/// Uniform whole-house preset when every zone uses the same override; None for a mixed state.
pub house_preset: Option<String>,
/// Whole-house master power state.
pub house_power: bool,
pub outdoor_temperature: Option<f64>,
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<ControlPlanEvent>,
pub zones: Vec<ZoneControlPlan>,
pub rules: Vec<AutomationPlanRule>,
}
#[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<u64>,
#[serde(default)]
pub broadcast: Option<String>,
/// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only.
#[serde(default)]
pub protocol_version: Option<u8>,
/// Number of scan broadcasts sent during one discovery operation.
#[serde(default)]
pub passes: Option<u8>,
}
#[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<String>,
#[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<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiEvent {
pub event: String,
pub timestamp: DateTime<Utc>,
pub data: Value,
}