1087 lines
40 KiB
Rust
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,
|
|
}
|