v0.12.0
This commit is contained in:
+12
-7
@@ -23,7 +23,7 @@ use crate::{
|
||||
home_assistant,
|
||||
influxdb,
|
||||
notifications,
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPatch, ZoneReading},
|
||||
models::{ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -98,11 +98,16 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/history", get(history))
|
||||
.route("/api/control-plan", get(control_plan))
|
||||
.route("/api/events", get(events))
|
||||
.route("/api/events/retention", get(get_event_retention).put(update_event_retention))
|
||||
.route("/api/settings", get(get_settings).put(update_settings))
|
||||
.route("/api/settings/export", get(export_settings))
|
||||
.route("/api/settings/import", post(import_settings))
|
||||
.route("/api/debug", get(get_debug).put(update_debug))
|
||||
.route("/api/settings/application", get(get_application_settings).put(update_application_settings))
|
||||
.route("/api/settings/gree", get(get_gree_settings).put(update_gree_settings))
|
||||
.route("/api/settings/history", get(get_history_settings).put(update_history_settings))
|
||||
.route("/api/settings/influxdb", get(get_influxdb_settings).put(update_influxdb_settings))
|
||||
.route("/api/settings/notifications", get(get_notification_settings).put(update_notification_settings))
|
||||
.route("/api/settings/night", get(get_night_settings).put(update_night_settings))
|
||||
.route("/api/settings/home-assistant", get(get_home_assistant_settings).put(update_home_assistant_settings))
|
||||
.route("/api/settings/debug", get(get_debug_settings).put(update_debug_settings))
|
||||
.route("/api/configuration/export", get(export_configuration))
|
||||
.route("/api/configuration/import", post(import_configuration))
|
||||
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
||||
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
||||
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
||||
@@ -186,9 +191,9 @@ include!("api/flows.rs");
|
||||
include!("api/history.rs");
|
||||
include!("api/events.rs");
|
||||
include!("api/settings.rs");
|
||||
include!("api/configuration.rs");
|
||||
include!("api/debug_tokens.rs");
|
||||
include!("api/integrations.rs");
|
||||
include!("api/middleware.rs");
|
||||
include!("api/public_settings.rs");
|
||||
include!("api/websocket.rs");
|
||||
include!("api/assets.rs");
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
struct ConfigurationIds<'a> {
|
||||
devices: std::collections::HashSet<&'a str>,
|
||||
zones: std::collections::HashSet<&'a str>,
|
||||
schedules: std::collections::HashSet<&'a str>,
|
||||
automations: std::collections::HashSet<&'a str>,
|
||||
flows: std::collections::HashSet<&'a str>,
|
||||
}
|
||||
|
||||
struct ConfigurationResourceGuards {
|
||||
_zones: Vec<tokio::sync::OwnedMutexGuard<()>>,
|
||||
_devices: Vec<tokio::sync::OwnedMutexGuard<()>>,
|
||||
}
|
||||
|
||||
async fn export_configuration(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut export = state.db.export_configuration(settings)?;
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
Ok(Json(export))
|
||||
}
|
||||
|
||||
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
if export.format_version != 3 {
|
||||
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.12.x".into()));
|
||||
}
|
||||
if export.settings.control_strategy != "setpoint" {
|
||||
return Err(AppError::BadRequest("import contains an unsupported control strategy".into()));
|
||||
}
|
||||
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_configuration_ids(export: &ConfigurationExport) -> Result<ConfigurationIds<'_>, AppError> {
|
||||
let ids = ConfigurationIds {
|
||||
devices: export.devices.iter().map(|item| item.id.as_str()).collect(),
|
||||
zones: export.zones.iter().map(|item| item.id.as_str()).collect(),
|
||||
schedules: export.schedules.iter().map(|item| item.id.as_str()).collect(),
|
||||
automations: export.automations.iter().map(|item| item.id.as_str()).collect(),
|
||||
flows: export.flows.iter().map(|item| item.id.as_str()).collect(),
|
||||
};
|
||||
let duplicate_or_empty = ids.devices.len() != export.devices.len()
|
||||
|| ids.zones.len() != export.zones.len()
|
||||
|| ids.schedules.len() != export.schedules.len()
|
||||
|| ids.automations.len() != export.automations.len()
|
||||
|| ids.flows.len() != export.flows.len()
|
||||
|| ids.devices.contains("")
|
||||
|| ids.zones.contains("")
|
||||
|| ids.schedules.contains("")
|
||||
|| ids.automations.contains("")
|
||||
|| ids.flows.contains("");
|
||||
if duplicate_or_empty {
|
||||
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn validate_configuration_flows(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
let draft_flows: std::collections::HashSet<&str> = export.flows.iter()
|
||||
.filter(|item| item.draft)
|
||||
.map(|item| item.id.as_str())
|
||||
.collect();
|
||||
let executable_draft = export.flows.iter().any(|item| item.draft
|
||||
&& (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()))
|
||||
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|
||||
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)));
|
||||
if executable_draft {
|
||||
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_devices_and_zones(
|
||||
export: &ConfigurationExport,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
) -> Result<(), AppError> {
|
||||
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
|
||||
if device_macs.len() != export.devices.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
|
||||
}
|
||||
if export.zones.iter().any(|item| !ids.devices.contains(item.device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
||||
}
|
||||
let mut zone_devices = std::collections::HashSet::new();
|
||||
for zone in &export.zones {
|
||||
if !zone_devices.insert(zone.device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
|
||||
}
|
||||
if !matches!(zone.mode.as_str(), "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
|
||||
}
|
||||
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_schedules(
|
||||
export: &ConfigurationExport,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
) -> Result<(), AppError> {
|
||||
if export.schedules.iter().any(|item| !ids.zones.contains(item.zone_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||
}
|
||||
for item in &export.schedules {
|
||||
if item.flow_id.as_deref().is_some_and(|flow_id| !ids.flows.contains(flow_id)) {
|
||||
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into()));
|
||||
}
|
||||
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
||||
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
||||
}
|
||||
NaiveTime::parse_from_str(&item.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
|
||||
NaiveTime::parse_from_str(&item.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
|
||||
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
|
||||
}
|
||||
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
|
||||
}
|
||||
}
|
||||
validate_schedule_set(&export.schedules)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_groups<'a>(
|
||||
export: &'a ConfigurationExport,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
) -> Result<std::collections::HashSet<&'a str>, AppError> {
|
||||
if export.groups.iter().any(|group| {
|
||||
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
|
||||
group.id.trim().is_empty()
|
||||
|| group.zone_ids.is_empty()
|
||||
|| members.len() != group.zone_ids.len()
|
||||
|| group.zone_ids.iter().any(|zone_id| !ids.zones.contains(zone_id.as_str()))
|
||||
}) {
|
||||
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
|
||||
}
|
||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||
if groups.len() != export.groups.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
|
||||
}
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
fn validate_configuration_automation_trigger(
|
||||
item: &Automation,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
) -> Result<(), AppError> {
|
||||
match item.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
|
||||
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
|
||||
};
|
||||
if !ids.devices.contains(trigger_id) || item.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = item.at_time.as_deref()
|
||||
.ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
||||
NaiveTime::parse_from_str(at, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
||||
}
|
||||
"flow" => {
|
||||
if item.flow_id.as_deref().filter(|id| ids.flows.contains(*id)).is_none() || item.flow_conditions.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into()));
|
||||
}
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_zone_automation(item: &Automation, ids: &ConfigurationIds<'_>) -> Result<(), AppError> {
|
||||
let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); };
|
||||
if !ids.zones.contains(zone_id) {
|
||||
return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into()));
|
||||
}
|
||||
if let Some(preset) = item.action_zone_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into()));
|
||||
}
|
||||
}
|
||||
if item.action_zone_preset.as_deref() == Some("custom")
|
||||
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value))
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_group_automation(
|
||||
item: &Automation,
|
||||
groups: &std::collections::HashSet<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); };
|
||||
if !groups.contains(group_id) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
||||
}
|
||||
if let Some(mode) = item.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
||||
}
|
||||
}
|
||||
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
|
||||
if let Some(preset) = item.action_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if flow_custom_group {
|
||||
let Some(target) = item.action.target_temperature else {
|
||||
return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into()));
|
||||
};
|
||||
if !(8.0..=30.0).contains(&target) {
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow group target".into()));
|
||||
}
|
||||
} else if item.action.target_temperature.is_some() {
|
||||
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into()));
|
||||
}
|
||||
if item.action.fan_speed.is_some()
|
||||
|| item.action.swing_vertical.is_some()
|
||||
|| item.action.swing_horizontal.is_some()
|
||||
|| item.action.quiet.is_some()
|
||||
|| item.action.turbo.is_some()
|
||||
|| item.action.light.is_some()
|
||||
|| item.action.air.is_some()
|
||||
|| item.action.xfan.is_some()
|
||||
|| item.action.health.is_some()
|
||||
|| item.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
|
||||
}
|
||||
if item.action.power.is_none()
|
||||
&& item.action.mode.is_none()
|
||||
&& item.action_preset.as_deref().filter(|value| !value.is_empty()).is_none()
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_shared_inputs(
|
||||
export: &ConfigurationExport,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
groups: &std::collections::HashSet<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
for item in &export.settings.home_assistant.flow_inputs {
|
||||
let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else { continue; };
|
||||
let exists = match reference {
|
||||
SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()),
|
||||
SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()),
|
||||
SharedInputResourceReference::Group(id) => groups.contains(id.as_str()),
|
||||
};
|
||||
if !exists {
|
||||
return Err(AppError::BadRequest("import contains a shared Flow input referencing a missing resource".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_automations(
|
||||
export: &ConfigurationExport,
|
||||
ids: &ConfigurationIds<'_>,
|
||||
groups: &std::collections::HashSet<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
for item in &export.automations {
|
||||
validate_configuration_automation_trigger(item, ids)?;
|
||||
if item.action_zone_id.as_deref().is_some_and(|value| !value.is_empty()) {
|
||||
validate_configuration_zone_automation(item, ids)?;
|
||||
} else if item.action_group_id.as_deref().is_some_and(|value| !value.is_empty()) {
|
||||
validate_configuration_group_automation(item, groups)?;
|
||||
} else {
|
||||
if !ids.devices.contains(item.action_device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
}
|
||||
engine::validate_command(&item.action)?;
|
||||
if item.action.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an empty automation action".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
validate_configuration_header(export)?;
|
||||
validate_configuration_flows(export)?;
|
||||
let ids = collect_configuration_ids(export)?;
|
||||
validate_configuration_devices_and_zones(export, &ids)?;
|
||||
validate_configuration_schedules(export, &ids)?;
|
||||
let groups = validate_configuration_groups(export, &ids)?;
|
||||
validate_configuration_shared_inputs(export, &ids, &groups)?;
|
||||
validate_configuration_automations(export, &ids, &groups)
|
||||
}
|
||||
|
||||
fn sanitize_imported_device(device: &mut Device, now: chrono::DateTime<Utc>) {
|
||||
device.power = false;
|
||||
device.mode = "cool".into();
|
||||
device.target_temperature = 23.0;
|
||||
device.fan_speed = 0;
|
||||
device.swing_vertical = false;
|
||||
device.swing_horizontal = false;
|
||||
device.quiet = false;
|
||||
device.turbo = false;
|
||||
device.light = false;
|
||||
device.air = false;
|
||||
device.xfan = false;
|
||||
device.health = false;
|
||||
device.sleep = false;
|
||||
device.current_temperature = None;
|
||||
device.outdoor_temperature = None;
|
||||
device.online = false;
|
||||
device.response_time_ms = None;
|
||||
device.last_seen = None;
|
||||
device.last_error = None;
|
||||
device.communication_failures = 0;
|
||||
device.updated_at = now;
|
||||
}
|
||||
|
||||
fn sanitize_imported_zone(zone: &mut Zone, now: chrono::DateTime<Utc>) {
|
||||
zone.device_temperature = None;
|
||||
zone.external_temperature = None;
|
||||
zone.current_temperature = None;
|
||||
zone.control_temperature_source = "device".into();
|
||||
zone.active_preset = "comfort".into();
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.temporary_quick_thermostat = None;
|
||||
zone.device_manual_override = false;
|
||||
zone.device_manual_override_since = None;
|
||||
zone.device_manual_override_until = None;
|
||||
zone.device_manual_override_fields.clear();
|
||||
zone.device_manual_override_baseline = None;
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = None;
|
||||
zone.control_resume_at = None;
|
||||
zone.control_reason = "Imported configuration; runtime ownership reset".into();
|
||||
zone.last_power_change_at = None;
|
||||
zone.last_mode_change_at = None;
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
zone.compressor_pending_action = None;
|
||||
zone.compressor_pending_since = None;
|
||||
zone.compressor_pending_until = None;
|
||||
zone.compressor_cancelled_action = None;
|
||||
zone.effective_mode.clear();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
zone.last_action_at = None;
|
||||
zone.revision = 0;
|
||||
zone.updated_at = now;
|
||||
}
|
||||
|
||||
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
|
||||
let now = Utc::now();
|
||||
for device in &mut export.devices { sanitize_imported_device(device, now.clone()); }
|
||||
for zone in &mut export.zones { sanitize_imported_zone(zone, now.clone()); }
|
||||
for automation in &mut export.automations {
|
||||
automation.last_fired_at = None;
|
||||
automation.updated_at = now.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result<(), AppError> {
|
||||
let gree = normalize_gree_settings(gree_settings(settings))?;
|
||||
settings.controller_id = gree.controller_id;
|
||||
settings.poll_interval_seconds = gree.poll_interval_seconds;
|
||||
settings.zone_interval_seconds = gree.zone_interval_seconds;
|
||||
settings.discovery_timeout_ms = gree.discovery_timeout_ms;
|
||||
settings.discovery_broadcast = gree.discovery_broadcast;
|
||||
settings.suppress_device_beep = gree.suppress_device_beep;
|
||||
settings.compressor_protection_enabled = gree.compressor_protection_enabled;
|
||||
settings.compressor_protection_seconds = gree.compressor_protection_seconds;
|
||||
|
||||
settings.history_retention_days = settings.history_retention_days.clamp(1, 3650);
|
||||
settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650);
|
||||
settings.influxdb.history_threshold_days = settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
influxdb::validate(&settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
|
||||
let current_notifications = settings.notifications.clone();
|
||||
settings.notifications = apply_notification_update(¤t_notifications, NotificationSettingsUpdate {
|
||||
enabled: current_notifications.enabled,
|
||||
mode: current_notifications.mode.clone(),
|
||||
provider: current_notifications.provider.clone(),
|
||||
pushover_app_token: Some(current_notifications.pushover_app_token.clone()),
|
||||
pushover_user_key: Some(current_notifications.pushover_user_key.clone()),
|
||||
slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()),
|
||||
discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()),
|
||||
cooldown_seconds: current_notifications.cooldown_seconds,
|
||||
communication_failure_threshold: current_notifications.communication_failure_threshold,
|
||||
target_timeout_minutes: current_notifications.target_timeout_minutes,
|
||||
alert_types: current_notifications.alert_types.clone(),
|
||||
})?;
|
||||
|
||||
settings.home_assistant.sensor_stale_after_seconds = settings.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400);
|
||||
normalize_sensor_aliases(&mut settings.home_assistant);
|
||||
normalize_flow_shared_inputs(&mut settings.home_assistant)?;
|
||||
canonicalize_home_assistant_entities(&mut settings.home_assistant);
|
||||
validate_home_assistant_url(&settings.home_assistant)?;
|
||||
validate_night_mode(&mut settings.night_mode)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_configuration_import(export: &mut ConfigurationExport) -> Result<(), AppError> {
|
||||
normalize_imported_runtime_settings(&mut export.settings)?;
|
||||
for zone in &mut export.zones {
|
||||
canonicalize_zone_ha_entity(zone, &export.settings.home_assistant);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn lock_configuration_resources(
|
||||
state: &AppState,
|
||||
current_zones: &[Zone],
|
||||
current_devices: &[Device],
|
||||
export: &ConfigurationExport,
|
||||
) -> ConfigurationResourceGuards {
|
||||
let mut zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone())
|
||||
.chain(export.zones.iter().map(|zone| zone.id.clone()))
|
||||
.collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
let mut zone_guards = Vec::with_capacity(zone_ids.len());
|
||||
for zone_id in &zone_ids { zone_guards.push(state.lock_zone_operation(zone_id).await); }
|
||||
|
||||
let mut device_ids: Vec<String> = current_devices.iter().map(|device| device.id.clone())
|
||||
.chain(export.devices.iter().map(|device| device.id.clone()))
|
||||
.collect();
|
||||
device_ids.sort();
|
||||
device_ids.dedup();
|
||||
let mut device_guards = Vec::with_capacity(device_ids.len());
|
||||
for device_id in &device_ids { device_guards.push(state.lock_device_operation(device_id).await); }
|
||||
|
||||
ConfigurationResourceGuards { _zones: zone_guards, _devices: device_guards }
|
||||
}
|
||||
|
||||
async fn power_off_detached_devices(
|
||||
state: &AppState,
|
||||
current_zones: &[Zone],
|
||||
export: &ConfigurationExport,
|
||||
) -> Result<(), AppError> {
|
||||
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
|
||||
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
|
||||
.collect();
|
||||
let detach_devices: std::collections::HashSet<String> = current_zones.iter()
|
||||
.filter(|current| imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()))
|
||||
.map(|current| current.device_id.clone())
|
||||
.collect();
|
||||
for device_id in detach_devices {
|
||||
let Some(device) = state.db.get_device(&device_id)? else { continue; };
|
||||
if !device.enabled {
|
||||
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
|
||||
}
|
||||
engine::force_power_off_device_locked(state, &device_id).await?;
|
||||
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||
"device_id": device.id, "source": "configuration.import"
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reconcile_imported_devices(state: &AppState, export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
|
||||
.filter(|zone| {
|
||||
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
zone.enabled && effective_mode != "off"
|
||||
})
|
||||
.map(|zone| zone.device_id.clone())
|
||||
.collect();
|
||||
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
|
||||
if let Err(err) = engine::force_power_off_device_locked(state, &device.id).await {
|
||||
state.log("error", "configuration.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
engine::poll_all_locked(state).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_configuration(
|
||||
State(state): State<AppState>,
|
||||
Json(mut export): Json<ConfigurationExport>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
validate_configuration_export(&export)?;
|
||||
prepare_configuration_import(&mut export)?;
|
||||
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let current_zones = state.db.list_zones()?;
|
||||
let current_devices = state.db.list_devices()?;
|
||||
let _resource_guards = lock_configuration_resources(&state, ¤t_zones, ¤t_devices, &export).await;
|
||||
|
||||
power_off_detached_devices(&state, ¤t_zones, &export).await?;
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
state.initial_device_sync_complete.store(false, Ordering::Release);
|
||||
state.db.replace_configuration(&export)?;
|
||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = export.settings.clone();
|
||||
reconcile_imported_devices(&state, &export).await?;
|
||||
state.initial_device_sync_complete.store(true, Ordering::Release);
|
||||
state.wake_zone_control();
|
||||
state.log("info", "configuration.imported", "Application configuration imported", json!({"format_version": export.format_version}));
|
||||
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
@@ -1,18 +1,3 @@
|
||||
async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
|
||||
Json(state.settings.read().await.debug.clone())
|
||||
}
|
||||
|
||||
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.debug = input.clone();
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
|
||||
state.broadcast("debug.settings", serde_json::to_value(&input)?);
|
||||
Ok(Json(input))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateAccessTokenRequest {
|
||||
name: Option<String>,
|
||||
|
||||
@@ -3,27 +3,3 @@ struct EventsQuery { limit: Option<u32> }
|
||||
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventRetentionInput { days: u32 }
|
||||
|
||||
async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
|
||||
let days = state.settings.read().await.event_log_retention_days;
|
||||
Json(json!({"days": days}))
|
||||
}
|
||||
|
||||
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.event_log_retention_days = input.days.clamp(1, 3650);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
let days = settings.event_log_retention_days;
|
||||
drop(settings);
|
||||
let removed = state.db.prune_events(days as i64)?;
|
||||
state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed}));
|
||||
let public = { let settings = state.settings.read().await; public_settings(&*settings) };
|
||||
state.broadcast("settings.updated", public);
|
||||
Ok(Json(json!({"days": days, "removed": removed})))
|
||||
}
|
||||
|
||||
|
||||
+53
-18
@@ -61,50 +61,85 @@ fn shared_input_comparison_kind(kind: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> {
|
||||
match kind {
|
||||
"outdoor_temperature" | "house_mode" | "night_mode" => {}
|
||||
enum SharedInputResourceReference {
|
||||
Device(String),
|
||||
Zone(String),
|
||||
Group(String),
|
||||
}
|
||||
|
||||
fn shared_input_resource_reference(kind: &str, config: &Value) -> Result<Option<SharedInputResourceReference>, AppError> {
|
||||
let reference = match kind {
|
||||
"outdoor_temperature" | "house_mode" | "night_mode" => None,
|
||||
"device_temperature" => {
|
||||
let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?;
|
||||
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); }
|
||||
let id = flow_string(config, "device_id")
|
||||
.ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?;
|
||||
Some(SharedInputResourceReference::Device(id))
|
||||
}
|
||||
"zone_temperature" => {
|
||||
let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?;
|
||||
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); }
|
||||
let id = flow_string(config, "zone_id")
|
||||
.ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?;
|
||||
Some(SharedInputResourceReference::Zone(id))
|
||||
}
|
||||
"ha_state" | "ha_numeric" | "ha_available" => {
|
||||
if flow_string(config, "entity_id").is_none() { return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into())); }
|
||||
if flow_string(config, "entity_id").is_none() {
|
||||
return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
"ha_attribute" => {
|
||||
if flow_string(config, "entity_id").is_none() || flow_string(config, "attribute").is_none() {
|
||||
return Err(AppError::BadRequest("shared Home Assistant attribute input needs entity_id and attribute".into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
"device_state" => {
|
||||
let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?;
|
||||
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); }
|
||||
let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared device state input needs a field".into()))?;
|
||||
let id = flow_string(config, "device_id")
|
||||
.ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?;
|
||||
let field = flow_string(config, "field")
|
||||
.ok_or_else(|| AppError::BadRequest("shared device state input needs a field".into()))?;
|
||||
if !matches!(field.as_str(), "enabled" | "online" | "power" | "mode" | "fan_speed" | "swing_vertical" | "swing_horizontal" | "quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep") {
|
||||
return Err(AppError::BadRequest("unsupported shared device state field".into()));
|
||||
}
|
||||
Some(SharedInputResourceReference::Device(id))
|
||||
}
|
||||
"zone_state" => {
|
||||
let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?;
|
||||
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); }
|
||||
let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared zone state input needs a field".into()))?;
|
||||
let id = flow_string(config, "zone_id")
|
||||
.ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?;
|
||||
let field = flow_string(config, "field")
|
||||
.ok_or_else(|| AppError::BadRequest("shared zone state input needs a field".into()))?;
|
||||
if !matches!(field.as_str(), "enabled" | "mode" | "active_preset" | "demand" | "control_owner" | "device_manual_override" | "local_thermostat_power") {
|
||||
return Err(AppError::BadRequest("unsupported shared zone state field".into()));
|
||||
}
|
||||
Some(SharedInputResourceReference::Zone(id))
|
||||
}
|
||||
"group_state" => {
|
||||
let id = flow_string(config, "group_id").ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?;
|
||||
if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing group".into())); }
|
||||
if flow_string(config, "field").as_deref() != Some("power_enabled") { return Err(AppError::BadRequest("unsupported shared group state field".into())); }
|
||||
let id = flow_string(config, "group_id")
|
||||
.ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?;
|
||||
if flow_string(config, "field").as_deref() != Some("power_enabled") {
|
||||
return Err(AppError::BadRequest("unsupported shared group state field".into()));
|
||||
}
|
||||
Some(SharedInputResourceReference::Group(id))
|
||||
}
|
||||
"constant" => {
|
||||
if config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("shared constant input needs a boolean value".into())); }
|
||||
if config.get("value").and_then(Value::as_bool).is_none() {
|
||||
return Err(AppError::BadRequest("shared constant input needs a boolean value".into()));
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {kind}"))),
|
||||
};
|
||||
Ok(reference)
|
||||
}
|
||||
|
||||
fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> {
|
||||
let Some(reference) = shared_input_resource_reference(kind, config)? else { return Ok(()); };
|
||||
let exists = match reference {
|
||||
SharedInputResourceReference::Device(id) => state.db.get_device(&id)?.is_some(),
|
||||
SharedInputResourceReference::Zone(id) => state.db.get_zone(&id)?.is_some(),
|
||||
SharedInputResourceReference::Group(id) => state.db.get_group(&id)?.is_some(),
|
||||
};
|
||||
if !exists {
|
||||
return Err(AppError::BadRequest("shared Flow input references a missing resource".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+5
-26
@@ -34,7 +34,7 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result
|
||||
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
engine::rearm_compressor_queue(&mut zone);
|
||||
if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; }
|
||||
engine::refresh_control_ownership(&mut zone, true);
|
||||
engine::refresh_control_ownership(&mut zone);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -82,14 +82,13 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
|
||||
}
|
||||
let mode = input.mode;
|
||||
let activate_all = mode != "off";
|
||||
let payload = {
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.house_mode = mode.clone();
|
||||
settings.house_power_enabled = true;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
public_settings(&settings)
|
||||
};
|
||||
state.broadcast("settings.updated", payload.clone());
|
||||
}
|
||||
let payload = json!({"mode": mode});
|
||||
state.broadcast("house.mode_changed", payload.clone());
|
||||
// House rules do not steal explicit local/group/manual ownership. Free zones follow the
|
||||
// new mode immediately; scoped manual controls continue independently.
|
||||
rearm_house_automation_compressor_queues(&state).await?;
|
||||
@@ -118,15 +117,6 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
// resurrect demand. ON releases that OFF state and records an explicit automatic house-power
|
||||
// intent for otherwise-free zones. Later explicit local/group/manual actions remain
|
||||
// independent and can take over only the selected scope.
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
if !settings.house_power_enabled {
|
||||
settings.house_power_enabled = true;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
state.broadcast("settings.updated", public_settings(&settings));
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the thermostat power intent before touching devices. The cycle lock held by this
|
||||
// handler guarantees that no setpoint-modulation cycle can race between the marker and OFF.
|
||||
let changed_zones = set_all_thermostat_power_state(&state, input.power).await?;
|
||||
@@ -135,9 +125,6 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
|
||||
let devices = state.db.list_devices()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let settings = state.settings.read().await;
|
||||
let settings_payload = public_settings(&settings);
|
||||
drop(settings);
|
||||
state.log("info", "house.power_all", if input.power {
|
||||
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
|
||||
} else {
|
||||
@@ -154,7 +141,6 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
|
||||
"one_shot": true,
|
||||
"devices": devices,
|
||||
"groups": groups,
|
||||
"settings": settings_payload,
|
||||
"failed": failed,
|
||||
})))
|
||||
}
|
||||
@@ -169,13 +155,6 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
|
||||
}
|
||||
|
||||
let settings_payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.house_power_enabled = true;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
public_settings(&settings)
|
||||
};
|
||||
state.broadcast("settings.updated", settings_payload.clone());
|
||||
// A house profile applies to free house-controlled zones. Explicit local/group/direct
|
||||
// owners remain higher priority and are not cleared or re-armed by changing house rules.
|
||||
rearm_house_automation_compressor_queues(&state).await?;
|
||||
|
||||
+404
-430
@@ -1,93 +1,330 @@
|
||||
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
||||
let settings = state.settings.read().await;
|
||||
Json(public_settings(&*settings))
|
||||
fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings {
|
||||
ApplicationSettings { simulator_enabled: settings.simulator_enabled }
|
||||
}
|
||||
|
||||
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let old = state.settings.read().await.clone();
|
||||
if input.house_mode != old.house_mode {
|
||||
return Err(AppError::BadRequest(
|
||||
"house_mode must be changed through the House Control API".into(),
|
||||
));
|
||||
fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
|
||||
GreeSettings {
|
||||
controller_id: settings.controller_id.clone(),
|
||||
poll_interval_seconds: settings.poll_interval_seconds,
|
||||
zone_interval_seconds: settings.zone_interval_seconds,
|
||||
discovery_timeout_ms: settings.discovery_timeout_ms,
|
||||
discovery_broadcast: settings.discovery_broadcast.clone(),
|
||||
suppress_device_beep: settings.suppress_device_beep,
|
||||
compressor_protection_enabled: settings.compressor_protection_enabled,
|
||||
compressor_protection_seconds: settings.compressor_protection_seconds,
|
||||
}
|
||||
}
|
||||
|
||||
fn history_settings(settings: &RuntimeSettings) -> HistorySettings {
|
||||
HistorySettings {
|
||||
retention_days: settings.history_retention_days,
|
||||
compaction_enabled: settings.history_compaction_enabled,
|
||||
event_retention_days: settings.event_log_retention_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn influxdb_settings(settings: &RuntimeSettings) -> InfluxDbSettingsView {
|
||||
InfluxDbSettingsView {
|
||||
enabled: settings.influxdb.enabled,
|
||||
version: settings.influxdb.version.clone(),
|
||||
url: settings.influxdb.url.clone(),
|
||||
database: settings.influxdb.database.clone(),
|
||||
username: settings.influxdb.username.clone(),
|
||||
password_configured: !settings.influxdb.password.trim().is_empty(),
|
||||
org: settings.influxdb.org.clone(),
|
||||
bucket: settings.influxdb.bucket.clone(),
|
||||
token_configured: !settings.influxdb.token.trim().is_empty(),
|
||||
history_threshold_days: settings.influxdb.history_threshold_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn notification_settings(settings: &RuntimeSettings) -> NotificationSettingsView {
|
||||
NotificationSettingsView {
|
||||
enabled: settings.notifications.enabled,
|
||||
mode: settings.notifications.mode.clone(),
|
||||
provider: settings.notifications.provider.clone(),
|
||||
pushover_configured: !settings.notifications.pushover_app_token.trim().is_empty()
|
||||
&& !settings.notifications.pushover_user_key.trim().is_empty(),
|
||||
slack_configured: !settings.notifications.slack_webhook_url.trim().is_empty(),
|
||||
discord_configured: !settings.notifications.discord_webhook_url.trim().is_empty(),
|
||||
cooldown_seconds: settings.notifications.cooldown_seconds,
|
||||
communication_failure_threshold: settings.notifications.communication_failure_threshold,
|
||||
target_timeout_minutes: settings.notifications.target_timeout_minutes,
|
||||
alert_types: settings.notifications.alert_types.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsView {
|
||||
HomeAssistantSettingsView {
|
||||
url: settings.home_assistant.url.clone(),
|
||||
token_configured: !settings.home_assistant.token.trim().is_empty(),
|
||||
default_entity_id: settings.home_assistant.default_entity_id.clone(),
|
||||
outdoor_entity_id: settings.home_assistant.outdoor_entity_id.clone(),
|
||||
sensor_stale_after_seconds: settings.home_assistant.sensor_stale_after_seconds,
|
||||
allow_invalid_tls: settings.home_assistant.allow_invalid_tls,
|
||||
sensor_aliases: settings.home_assistant.sensor_aliases.clone(),
|
||||
flow_inputs: settings.home_assistant.flow_inputs.clone(),
|
||||
outdoor_assist_enabled: settings.outdoor_assist_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_application_settings(State(state): State<AppState>) -> Json<ApplicationSettings> {
|
||||
Json(application_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
async fn update_application_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ApplicationSettings>,
|
||||
) -> Result<Json<ApplicationSettings>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.simulator_enabled = input.simulator_enabled;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
application_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled}));
|
||||
state.broadcast("settings.application.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_gree_settings(State(state): State<AppState>) -> Json<GreeSettings> {
|
||||
Json(gree_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn normalize_gree_settings(mut input: GreeSettings) -> Result<GreeSettings, AppError> {
|
||||
input.controller_id = input.controller_id.trim().to_string();
|
||||
if input.controller_id.is_empty() {
|
||||
return Err(AppError::BadRequest("controller_id cannot be empty".into()));
|
||||
}
|
||||
// Compatibility-only field: global ON/OFF no longer uses a persistent master automation gate.
|
||||
input.house_power_enabled = true;
|
||||
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
|
||||
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
|
||||
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
|
||||
if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
|
||||
if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); }
|
||||
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
|
||||
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|
||||
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
|
||||
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:"))
|
||||
{
|
||||
input.discovery_broadcast.parse::<std::net::SocketAddr>()
|
||||
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
|
||||
}
|
||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||
input.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400);
|
||||
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
|
||||
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
|
||||
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
|
||||
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
|
||||
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
|
||||
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
|
||||
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
|
||||
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
|
||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
|
||||
let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled
|
||||
|| input.compressor_protection_seconds != old.compressor_protection_seconds;
|
||||
normalize_sensor_aliases(&mut input);
|
||||
validate_flow_shared_inputs(&mut input, &state)?;
|
||||
canonicalize_home_assistant_entities(&mut input);
|
||||
validate_night_mode(&mut input)?;
|
||||
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
|
||||
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
|
||||
influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if !input.home_assistant.url.trim().is_empty() {
|
||||
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
|
||||
}
|
||||
state.db.save_runtime_settings(&input)?;
|
||||
canonicalize_saved_zone_entities(&state, &input).await?;
|
||||
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = input.clone();
|
||||
if compressor_settings_changed {
|
||||
// A changed protection policy invalidates old deadlines. Clear transient tasks under
|
||||
// the same control-cycle exclusion; the next thermostat pass re-evaluates intent
|
||||
// against the new enabled flag/duration.
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
for zone_id in zone_ids {
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
|
||||
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
|
||||
engine::clear_compressor_pending(&mut zone, true);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
}
|
||||
state.log("info", "settings.updated", "Settings updated", json!({
|
||||
"compressor_protection_enabled": input.compressor_protection_enabled,
|
||||
"compressor_protection_seconds": input.compressor_protection_seconds
|
||||
}));
|
||||
state.broadcast("settings.updated", public_settings(&input));
|
||||
state.wake_zone_control();
|
||||
Ok(Json(public_settings(&input)))
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||
settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases
|
||||
async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
for zone_id in zone_ids {
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
if zone.compressor_pending_action.is_none()
|
||||
&& zone.compressor_cancelled_action.is_none()
|
||||
&& zone.lockout_until.is_none()
|
||||
&& zone.lockout_reason.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
engine::clear_compressor_pending(&mut zone, true);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_gree_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<GreeSettings>,
|
||||
) -> Result<Json<GreeSettings>, AppError> {
|
||||
let input = normalize_gree_settings(input)?;
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let (payload, compressor_changed) = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let compressor_changed = settings.compressor_protection_enabled != input.compressor_protection_enabled
|
||||
|| settings.compressor_protection_seconds != input.compressor_protection_seconds;
|
||||
settings.controller_id = input.controller_id;
|
||||
settings.poll_interval_seconds = input.poll_interval_seconds;
|
||||
settings.zone_interval_seconds = input.zone_interval_seconds;
|
||||
settings.discovery_timeout_ms = input.discovery_timeout_ms;
|
||||
settings.discovery_broadcast = input.discovery_broadcast;
|
||||
settings.suppress_device_beep = input.suppress_device_beep;
|
||||
settings.compressor_protection_enabled = input.compressor_protection_enabled;
|
||||
settings.compressor_protection_seconds = input.compressor_protection_seconds;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
(gree_settings(&settings), compressor_changed)
|
||||
};
|
||||
if compressor_changed {
|
||||
clear_compressor_runtime_after_settings_change(&state).await?;
|
||||
}
|
||||
state.log("info", "settings.gree.updated", "GREE settings updated", json!({
|
||||
"compressor_protection_enabled": payload.compressor_protection_enabled,
|
||||
"compressor_protection_seconds": payload.compressor_protection_seconds
|
||||
}));
|
||||
state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_history_settings(State(state): State<AppState>) -> Json<HistorySettings> {
|
||||
Json(history_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
async fn update_history_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(mut input): Json<HistorySettings>,
|
||||
) -> Result<Json<HistorySettings>, AppError> {
|
||||
input.retention_days = input.retention_days.clamp(1, 3650);
|
||||
input.event_retention_days = input.event_retention_days.clamp(1, 3650);
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.history_retention_days = input.retention_days;
|
||||
settings.history_compaction_enabled = input.compaction_enabled;
|
||||
settings.event_log_retention_days = input.event_retention_days;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
history_settings(&settings)
|
||||
};
|
||||
let removed = state.db.prune_events(payload.event_retention_days as i64)?;
|
||||
state.log("info", "settings.history.updated", "History settings updated", json!({
|
||||
"retention_days": payload.retention_days,
|
||||
"event_retention_days": payload.event_retention_days,
|
||||
"event_rows_removed": removed
|
||||
}));
|
||||
state.broadcast("settings.history.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_influxdb_settings(State(state): State<AppState>) -> Json<InfluxDbSettingsView> {
|
||||
Json(influxdb_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpdate) -> Result<InfluxDbSettings, AppError> {
|
||||
let mut next = InfluxDbSettings {
|
||||
enabled: input.enabled,
|
||||
version: input.version,
|
||||
url: input.url,
|
||||
database: input.database,
|
||||
username: input.username,
|
||||
password: current.password.clone(),
|
||||
org: input.org,
|
||||
bucket: input.bucket,
|
||||
token: current.token.clone(),
|
||||
history_threshold_days: input.history_threshold_days.clamp(1, 3650),
|
||||
};
|
||||
if let Some(password) = input.password { next.password = password; }
|
||||
if let Some(token) = input.token { next.token = token; }
|
||||
influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
async fn update_influxdb_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<InfluxDbSettingsUpdate>,
|
||||
) -> Result<Json<InfluxDbSettingsView>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let next = apply_influxdb_update(&settings.influxdb, input)?;
|
||||
settings.influxdb = next;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
influxdb_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.influxdb.updated", "InfluxDB settings updated", json!({"enabled": payload.enabled, "version": payload.version}));
|
||||
state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_notification_settings(State(state): State<AppState>) -> Json<NotificationSettingsView> {
|
||||
Json(notification_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn apply_notification_update(current: &NotificationSettings, mut input: NotificationSettingsUpdate) -> Result<NotificationSettings, AppError> {
|
||||
input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400);
|
||||
input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100);
|
||||
input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
if !matches!(input.mode.as_str(), "problems" | "important") {
|
||||
return Err(AppError::BadRequest("notification mode must be problems or important".into()));
|
||||
}
|
||||
if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") {
|
||||
return Err(AppError::BadRequest("unsupported notification provider".into()));
|
||||
}
|
||||
let mut next = NotificationSettings {
|
||||
enabled: input.enabled,
|
||||
mode: input.mode,
|
||||
provider: input.provider,
|
||||
pushover_app_token: current.pushover_app_token.clone(),
|
||||
pushover_user_key: current.pushover_user_key.clone(),
|
||||
slack_webhook_url: current.slack_webhook_url.clone(),
|
||||
discord_webhook_url: current.discord_webhook_url.clone(),
|
||||
cooldown_seconds: input.cooldown_seconds,
|
||||
communication_failure_threshold: input.communication_failure_threshold,
|
||||
target_timeout_minutes: input.target_timeout_minutes,
|
||||
alert_types: input.alert_types,
|
||||
};
|
||||
if let Some(value) = input.pushover_app_token { next.pushover_app_token = value; }
|
||||
if let Some(value) = input.pushover_user_key { next.pushover_user_key = value; }
|
||||
if let Some(value) = input.slack_webhook_url { next.slack_webhook_url = value; }
|
||||
if let Some(value) = input.discord_webhook_url { next.discord_webhook_url = value; }
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
async fn update_notification_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<NotificationSettingsUpdate>,
|
||||
) -> Result<Json<NotificationSettingsView>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let next = apply_notification_update(&settings.notifications, input)?;
|
||||
settings.notifications = next;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
notification_settings(&settings)
|
||||
};
|
||||
state.log("info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider}));
|
||||
state.broadcast("settings.notifications.updated", serde_json::to_value(&payload)?);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_night_settings(State(state): State<AppState>) -> Json<NightModeSettings> {
|
||||
Json(state.settings.read().await.night_mode.clone())
|
||||
}
|
||||
|
||||
fn validate_night_mode(settings: &mut NightModeSettings) -> Result<(), AppError> {
|
||||
NaiveTime::parse_from_str(&settings.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
|
||||
NaiveTime::parse_from_str(&settings.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
|
||||
settings.max_fan_speed = settings.max_fan_speed.clamp(1, 5);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_night_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(mut input): Json<NightModeSettings>,
|
||||
) -> Result<Json<NightModeSettings>, AppError> {
|
||||
validate_night_mode(&mut input)?;
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.night_mode = input.clone();
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
state.log("info", "settings.night.updated", "Night mode settings updated", json!({"enabled": input.enabled}));
|
||||
state.broadcast("settings.night.updated", serde_json::to_value(&input)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(input))
|
||||
}
|
||||
|
||||
async fn get_home_assistant_settings(State(state): State<AppState>) -> Json<HomeAssistantSettingsView> {
|
||||
Json(home_assistant_settings(&state.settings.read().await))
|
||||
}
|
||||
|
||||
fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
|
||||
settings.sensor_aliases = settings.sensor_aliases
|
||||
.iter()
|
||||
.filter_map(|(entity, alias)| {
|
||||
let entity = entity.trim();
|
||||
@@ -98,12 +335,12 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) -> Result<(), AppError> {
|
||||
fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> {
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
if settings.home_assistant.flow_inputs.len() > 128 {
|
||||
if settings.flow_inputs.len() > 128 {
|
||||
return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into()));
|
||||
}
|
||||
for item in &mut settings.home_assistant.flow_inputs {
|
||||
for item in &mut settings.flow_inputs {
|
||||
item.id = item.id.trim().chars().take(120).collect();
|
||||
item.name = item.name.trim().chars().take(100).collect();
|
||||
item.kind = item.kind.trim().to_string();
|
||||
@@ -129,30 +366,47 @@ fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState)
|
||||
if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() {
|
||||
return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_flow_shared_inputs(settings: &mut HomeAssistantSettings, state: &AppState) -> Result<(), AppError> {
|
||||
normalize_flow_shared_inputs(settings)?;
|
||||
for item in &settings.flow_inputs {
|
||||
validate_shared_input_source(&item.kind, &item.config, state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) {
|
||||
let default_entity = settings.home_assistant.default_entity_id.clone();
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) {
|
||||
settings.home_assistant.default_entity_id = entity_id;
|
||||
fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) {
|
||||
let default_entity = settings.default_entity_id.clone();
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&default_entity)) {
|
||||
settings.default_entity_id = entity_id;
|
||||
}
|
||||
let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone();
|
||||
let outdoor_entity = settings.outdoor_entity_id.clone();
|
||||
if !outdoor_entity.trim().is_empty() {
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) {
|
||||
settings.home_assistant.outdoor_entity_id = entity_id;
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) {
|
||||
settings.outdoor_entity_id = entity_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
|
||||
let Some(configured) = zone.ha_entity_id.clone() else { return; };
|
||||
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
|
||||
fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> {
|
||||
if settings.url.trim().is_empty() { return Ok(()); }
|
||||
let parsed = url::Url::parse(&settings.url)
|
||||
.map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") {
|
||||
return Err(AppError::BadRequest("Home Assistant URL must use http or https".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
|
||||
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) {
|
||||
let Some(configured) = zone.ha_entity_id.clone() else { return; };
|
||||
zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured));
|
||||
}
|
||||
|
||||
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &HomeAssistantSettings) -> Result<(), AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
@@ -172,348 +426,68 @@ async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSe
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
|
||||
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
|
||||
NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
|
||||
settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||
Ok(())
|
||||
fn apply_home_assistant_update(
|
||||
current: &HomeAssistantSettings,
|
||||
input: HomeAssistantSettingsUpdate,
|
||||
) -> HomeAssistantSettings {
|
||||
let mut next = HomeAssistantSettings {
|
||||
url: input.url,
|
||||
token: current.token.clone(),
|
||||
default_entity_id: input.default_entity_id,
|
||||
outdoor_entity_id: input.outdoor_entity_id,
|
||||
sensor_stale_after_seconds: input.sensor_stale_after_seconds.clamp(30, 86_400),
|
||||
allow_invalid_tls: input.allow_invalid_tls,
|
||||
sensor_aliases: input.sensor_aliases,
|
||||
flow_inputs: input.flow_inputs,
|
||||
};
|
||||
if let Some(token) = input.token { next.token = token; }
|
||||
next
|
||||
}
|
||||
|
||||
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut export = state.db.export_configuration(settings)?;
|
||||
// Backups are configuration snapshots, not a way to resurrect transient ownership,
|
||||
// timers or a stale physical device state after restore (K9).
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
Ok(Json(export))
|
||||
}
|
||||
|
||||
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
if !matches!(export.format_version, 1 | 2) { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
||||
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||
}
|
||||
|
||||
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
|
||||
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
||||
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
||||
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
||||
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
|
||||
let draft_flows: std::collections::HashSet<&str> = export.flows.iter().filter(|item| item.draft).map(|item| item.id.as_str()).collect();
|
||||
if export.flows.iter().any(|item| item.draft && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()))
|
||||
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|
||||
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
|
||||
}
|
||||
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
||||
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|
||||
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
||||
}
|
||||
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
|
||||
if device_macs.len() != export.devices.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
|
||||
}
|
||||
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
||||
}
|
||||
let mut zone_devices = std::collections::HashSet::new();
|
||||
for zone in &export.zones {
|
||||
if !zone_devices.insert(zone.device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
|
||||
}
|
||||
if !matches!(zone.mode.as_str(), "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
|
||||
}
|
||||
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||
}
|
||||
for item in &export.schedules {
|
||||
if item.flow_id.as_deref().is_some_and(|flow_id| !flows.contains(flow_id)) {
|
||||
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into()));
|
||||
}
|
||||
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
||||
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
||||
}
|
||||
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
|
||||
NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
|
||||
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
|
||||
}
|
||||
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
|
||||
}
|
||||
}
|
||||
validate_schedule_set(&export.schedules)?;
|
||||
|
||||
if export.groups.iter().any(|group| {
|
||||
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
|
||||
group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len()
|
||||
|| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))
|
||||
}) {
|
||||
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
|
||||
}
|
||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||
if groups.len() != export.groups.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
|
||||
}
|
||||
|
||||
for item in &export.automations {
|
||||
match item.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
|
||||
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
|
||||
};
|
||||
if !devices.contains(trigger_id) || item.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
||||
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
||||
}
|
||||
"flow" => {
|
||||
if item.flow_id.as_deref().filter(|id| flows.contains(*id)).is_none() || item.flow_conditions.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into()));
|
||||
}
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
||||
}
|
||||
|
||||
if let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
if !zones.contains(zone_id) { return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); }
|
||||
if let Some(preset) = item.action_zone_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into()));
|
||||
}
|
||||
}
|
||||
if item.action_zone_preset.as_deref() == Some("custom")
|
||||
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value))
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into()));
|
||||
}
|
||||
} else if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
if !groups.contains(group_id) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
||||
}
|
||||
if let Some(mode) = item.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
||||
}
|
||||
}
|
||||
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
|
||||
if let Some(preset) = item.action_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if flow_custom_group {
|
||||
let Some(target) = item.action.target_temperature else { return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); };
|
||||
if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); }
|
||||
} else if item.action.target_temperature.is_some() {
|
||||
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into()));
|
||||
}
|
||||
if item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|
||||
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|
||||
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
|
||||
}
|
||||
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
|
||||
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
||||
}
|
||||
} else {
|
||||
if !devices.contains(item.action_device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
}
|
||||
engine::validate_command(&item.action)?;
|
||||
if item.action.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an empty automation action".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
|
||||
let now = Utc::now();
|
||||
// Do not restore the pre-v0.9.3 persistent global-OFF gate from backups.
|
||||
export.settings.house_power_enabled = true;
|
||||
for device in &mut export.devices {
|
||||
device.power = false;
|
||||
device.mode = "cool".into();
|
||||
device.target_temperature = 23.0;
|
||||
device.fan_speed = 0;
|
||||
device.swing_vertical = false;
|
||||
device.swing_horizontal = false;
|
||||
device.quiet = false;
|
||||
device.turbo = false;
|
||||
device.light = false;
|
||||
device.air = false;
|
||||
device.xfan = false;
|
||||
device.health = false;
|
||||
device.sleep = false;
|
||||
device.current_temperature = None;
|
||||
device.outdoor_temperature = None;
|
||||
device.online = false;
|
||||
device.response_time_ms = None;
|
||||
device.last_seen = None;
|
||||
device.last_error = None;
|
||||
device.communication_failures = 0;
|
||||
device.updated_at = now;
|
||||
}
|
||||
for zone in &mut export.zones {
|
||||
zone.device_temperature = None;
|
||||
zone.external_temperature = None;
|
||||
zone.current_temperature = None;
|
||||
zone.control_temperature_source = "device".into();
|
||||
zone.active_preset = "comfort".into();
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.temporary_quick_thermostat = None;
|
||||
zone.device_manual_override = false;
|
||||
zone.device_manual_override_since = None;
|
||||
zone.device_manual_override_until = None;
|
||||
zone.device_manual_override_fields.clear();
|
||||
zone.device_manual_override_baseline = None;
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = None;
|
||||
zone.control_resume_at = None;
|
||||
zone.control_reason = "Imported configuration; runtime ownership reset".into();
|
||||
zone.last_power_change_at = None;
|
||||
zone.last_mode_change_at = None;
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
zone.compressor_pending_action = None;
|
||||
zone.compressor_pending_since = None;
|
||||
zone.compressor_pending_until = None;
|
||||
zone.compressor_cancelled_action = None;
|
||||
zone.effective_mode.clear();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
zone.last_action_at = None;
|
||||
zone.revision = 0;
|
||||
zone.updated_at = now;
|
||||
}
|
||||
for automation in &mut export.automations {
|
||||
automation.last_fired_at = None;
|
||||
automation.updated_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
|
||||
validate_configuration_export(&export)?;
|
||||
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
|
||||
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
|
||||
export.settings.compressor_protection_seconds = export.settings.compressor_protection_seconds.clamp(30, 1800);
|
||||
normalize_sensor_aliases(&mut export.settings);
|
||||
canonicalize_home_assistant_entities(&mut export.settings);
|
||||
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
|
||||
validate_night_mode(&mut export.settings)?;
|
||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
|
||||
// Configuration replacement is the broadest mutation in the application. Serialize it
|
||||
// against every structural editor and every live owner that can write zone/device state.
|
||||
// Global lock order: configuration -> automation -> house -> schedule -> thermostat-cycle -> zones -> devices.
|
||||
async fn update_home_assistant_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<HomeAssistantSettingsUpdate>,
|
||||
) -> Result<Json<HomeAssistantSettingsView>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
|
||||
let current_zones = state.db.list_zones()?;
|
||||
let current_devices = state.db.list_devices()?;
|
||||
let mut locked_zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone())
|
||||
.chain(export.zones.iter().map(|zone| zone.id.clone()))
|
||||
.collect();
|
||||
locked_zone_ids.sort();
|
||||
locked_zone_ids.dedup();
|
||||
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
|
||||
for zone_id in &locked_zone_ids {
|
||||
_zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
|
||||
let mut locked_device_ids: Vec<String> = current_devices.iter().map(|device| device.id.clone())
|
||||
.chain(export.devices.iter().map(|device| device.id.clone()))
|
||||
.collect();
|
||||
locked_device_ids.sort();
|
||||
locked_device_ids.dedup();
|
||||
let mut _device_guards = Vec::with_capacity(locked_device_ids.len());
|
||||
for device_id in &locked_device_ids {
|
||||
_device_guards.push(state.lock_device_operation(device_id).await);
|
||||
}
|
||||
|
||||
// Before replacing ownership, safely stop every currently managed device whose zone is
|
||||
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
|
||||
// could keep running after its database owner disappears.
|
||||
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
|
||||
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
|
||||
.collect();
|
||||
let mut detach_devices = std::collections::HashSet::new();
|
||||
for current in ¤t_zones {
|
||||
if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) {
|
||||
detach_devices.insert(current.device_id.clone());
|
||||
}
|
||||
}
|
||||
for device_id in detach_devices {
|
||||
let Some(device) = state.db.get_device(&device_id)? else { continue; };
|
||||
if !device.enabled {
|
||||
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
|
||||
}
|
||||
engine::force_power_off_device_locked(&state, &device_id).await?;
|
||||
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||
"device_id": device.id, "source": "configuration.import"
|
||||
}));
|
||||
}
|
||||
|
||||
// Configuration import never restores ephemeral owners/timers or cached physical state.
|
||||
// Imported devices are reconciled from a fresh poll and current house/zone gates.
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
state.initial_device_sync_complete.store(false, Ordering::Release);
|
||||
state.db.replace_configuration(&export)?;
|
||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = export.settings.clone();
|
||||
|
||||
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
|
||||
.filter(|zone| {
|
||||
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
zone.enabled
|
||||
&& effective_mode != "off"
|
||||
})
|
||||
.map(|zone| zone.device_id.clone())
|
||||
.collect();
|
||||
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
|
||||
if let Err(err) = engine::force_power_off_device_locked(&state, &device.id).await {
|
||||
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
// Rebuild live device snapshots before allowing the thermostat loop to make decisions.
|
||||
// Network failures are represented in device health by poll_one rather than reviving
|
||||
// imported cache values.
|
||||
engine::poll_all_locked(&state).await?;
|
||||
state.initial_device_sync_complete.store(true, Ordering::Release);
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let mut next = apply_home_assistant_update(&settings.home_assistant, input.clone());
|
||||
normalize_sensor_aliases(&mut next);
|
||||
validate_flow_shared_inputs(&mut next, &state)?;
|
||||
canonicalize_home_assistant_entities(&mut next);
|
||||
validate_home_assistant_url(&next)?;
|
||||
settings.home_assistant = next.clone();
|
||||
settings.outdoor_assist_enabled = input.outdoor_assist_enabled;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
home_assistant_settings(&settings)
|
||||
};
|
||||
let saved = state.settings.read().await.home_assistant.clone();
|
||||
canonicalize_saved_zone_entities(&state, &saved).await?;
|
||||
state.log("info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({
|
||||
"configured": payload.token_configured,
|
||||
"flow_inputs": payload.flow_inputs.len()
|
||||
}));
|
||||
state.broadcast("settings.home_assistant.updated", serde_json::to_value(&payload)?);
|
||||
state.wake_zone_control();
|
||||
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
|
||||
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
|
||||
Ok(Json(json!({"ok": true})))
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_debug_settings(State(state): State<AppState>) -> Json<DebugSettings> {
|
||||
Json(state.settings.read().await.debug.clone())
|
||||
}
|
||||
|
||||
async fn update_debug_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<DebugSettings>,
|
||||
) -> Result<Json<DebugSettings>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.debug = input.clone();
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
|
||||
state.broadcast("settings.debug.updated", serde_json::to_value(&input)?);
|
||||
Ok(Json(input))
|
||||
}
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
"automations": state.db.list_automations()?,
|
||||
"flows": state.db.list_flows()?,
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
"settings": public_settings(&settings),
|
||||
"house": {"mode": settings.house_mode},
|
||||
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
||||
"system": {
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
|
||||
+236
-203
@@ -252,6 +252,240 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
struct TemporaryStartTiming {
|
||||
start_kind: String,
|
||||
started_at: chrono::DateTime<Utc>,
|
||||
activated_at: Option<chrono::DateTime<Utc>>,
|
||||
editing_active: bool,
|
||||
}
|
||||
|
||||
struct TemporaryTargetSettings {
|
||||
target: f64,
|
||||
tolerance: f64,
|
||||
temperature_operator: String,
|
||||
is_temperature_condition: bool,
|
||||
}
|
||||
|
||||
struct TemporaryFinishTiming {
|
||||
duration_seconds: Option<u64>,
|
||||
hold_seconds: u64,
|
||||
safety_duration_seconds: Option<u64>,
|
||||
expires_at: Option<chrono::DateTime<Utc>>,
|
||||
safety_expires_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
fn resolve_temporary_start(
|
||||
zone: &Zone,
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
now: chrono::DateTime<Utc>,
|
||||
) -> Result<TemporaryStartTiming, AppError> {
|
||||
let existing = zone.temporary_quick_thermostat.as_ref();
|
||||
let editing_active = engine::temporary_quick_thermostat_is_active(zone, now.clone());
|
||||
if !matches!(request.start_kind.as_str(), "now" | "delay" | "at") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
|
||||
}
|
||||
if editing_active {
|
||||
let existing = existing.expect("active temporary session must exist");
|
||||
return Ok(TemporaryStartTiming {
|
||||
start_kind: existing.start_kind.clone(),
|
||||
started_at: existing.started_at.clone(),
|
||||
activated_at: existing.activated_at.clone(),
|
||||
editing_active: true,
|
||||
});
|
||||
}
|
||||
let started_at = match request.start_kind.as_str() {
|
||||
"now" => now,
|
||||
"delay" => {
|
||||
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
|
||||
if !(1..=43_200).contains(&minutes) {
|
||||
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
|
||||
}
|
||||
now + ChronoDuration::minutes(minutes as i64)
|
||||
}
|
||||
"at" => {
|
||||
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
|
||||
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
|
||||
if at > now + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
|
||||
at
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok(TemporaryStartTiming {
|
||||
start_kind: request.start_kind.clone(),
|
||||
started_at,
|
||||
activated_at: None,
|
||||
editing_active: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_temporary_target(
|
||||
zone: &Zone,
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
) -> Result<TemporaryTargetSettings, AppError> {
|
||||
let finish_kind = request.finish_kind.as_str();
|
||||
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
|
||||
}
|
||||
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
|
||||
if !(8.0..=30.0).contains(&target) {
|
||||
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
|
||||
}
|
||||
let target = (target * 2.0).round() / 2.0;
|
||||
let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() };
|
||||
let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0);
|
||||
let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3));
|
||||
if !(0.1..=3.0).contains(&requested_tolerance) {
|
||||
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
|
||||
}
|
||||
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
|
||||
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
|
||||
}
|
||||
Ok(TemporaryTargetSettings {
|
||||
target,
|
||||
tolerance: if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance },
|
||||
temperature_operator: temperature_operator.to_string(),
|
||||
is_temperature_condition: matches!(finish_kind, "temperature_reached" | "temperature_stable"),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_temporary_finish(
|
||||
zone: &Zone,
|
||||
schedules: &[Schedule],
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
start: &TemporaryStartTiming,
|
||||
now: chrono::DateTime<Utc>,
|
||||
is_temperature_condition: bool,
|
||||
) -> Result<TemporaryFinishTiming, AppError> {
|
||||
let finish_kind = request.finish_kind.as_str();
|
||||
let duration_seconds = if finish_kind == "duration" {
|
||||
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
|
||||
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
|
||||
Some(minutes.saturating_mul(60))
|
||||
} else { None };
|
||||
let hold_seconds = if finish_kind == "temperature_stable" {
|
||||
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
|
||||
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
|
||||
minutes.saturating_mul(60)
|
||||
} else { 0 };
|
||||
let safety_duration_seconds = if is_temperature_condition {
|
||||
request.max_duration_minutes.map(|minutes| {
|
||||
if !(1..=14_400).contains(&minutes) {
|
||||
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
|
||||
}
|
||||
Ok(minutes.saturating_mul(60))
|
||||
}).transpose()?
|
||||
} else { None };
|
||||
let active_base = start.activated_at.clone().unwrap_or(now.clone());
|
||||
let expires_at = match finish_kind {
|
||||
"duration" if start.editing_active => duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)),
|
||||
"until" => {
|
||||
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
|
||||
let comparison_start = if start.editing_active { now.clone() } else { start.started_at.clone() };
|
||||
if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); }
|
||||
if until > comparison_start + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
|
||||
Some(until)
|
||||
}
|
||||
"schedule_boundary" => {
|
||||
let reference = if start.editing_active { chrono::Local::now() } else { start.started_at.clone().with_timezone(&chrono::Local) };
|
||||
Some(engine::next_schedule_boundary_utc(&zone.id, schedules, reference)
|
||||
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let safety_expires_at = if start.editing_active {
|
||||
safety_duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64))
|
||||
} else { None };
|
||||
Ok(TemporaryFinishTiming { duration_seconds, hold_seconds, safety_duration_seconds, expires_at, safety_expires_at })
|
||||
}
|
||||
|
||||
async fn apply_temporary_quick_thermostat_request(
|
||||
state: &AppState,
|
||||
zone: &mut Zone,
|
||||
schedules: &[Schedule],
|
||||
request: &TemporaryQuickThermostatRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let existing_session = zone.temporary_quick_thermostat.clone();
|
||||
let start = resolve_temporary_start(zone, request, now.clone())?;
|
||||
let target = resolve_temporary_target(zone, request)?;
|
||||
let finish = resolve_temporary_finish(zone, schedules, request, &start, now.clone(), target.is_temperature_condition)?;
|
||||
let starts_now = start.start_kind == "now";
|
||||
let immediate_activation = !start.editing_active && starts_now && !zone.device_manual_override;
|
||||
let restore_zone_enabled = if start.editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
|
||||
} else if immediate_activation { Some(zone.enabled) } else { None };
|
||||
let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() };
|
||||
let active_mode = if start.editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.active_mode.clone())
|
||||
} else if immediate_activation { Some(captured_mode.clone()) } else { None };
|
||||
let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str());
|
||||
if target.is_temperature_condition
|
||||
&& ((condition_mode == "heat" && target.temperature_operator == "at_or_below")
|
||||
|| (condition_mode == "cool" && target.temperature_operator == "at_or_above"))
|
||||
{
|
||||
return Err(AppError::BadRequest("temporary thermostat temperature condition conflicts with the active heating/cooling direction".into()));
|
||||
}
|
||||
let state_value = if start.editing_active {
|
||||
if zone.device_manual_override { "paused_manual" } else { "active" }
|
||||
} else if starts_now && zone.device_manual_override { "paused_manual" } else { "scheduled" };
|
||||
|
||||
let underlying_local_power = zone.local_thermostat_power;
|
||||
let underlying_local_resume_at = zone.local_thermostat_resume_at;
|
||||
let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
|
||||
let underlying_manual_preset = zone.manual_preset.clone();
|
||||
let underlying_manual_setpoint = zone.manual_setpoint;
|
||||
let underlying_manual_override_until = zone.manual_override_until;
|
||||
if immediate_activation {
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.enabled = true;
|
||||
zone.manual_setpoint = Some(target.target);
|
||||
zone.effective_setpoint = Some(target.target);
|
||||
zone.manual_override_until = None;
|
||||
} else if start.editing_active {
|
||||
zone.manual_setpoint = Some(target.target);
|
||||
zone.effective_setpoint = Some(target.target);
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
|
||||
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
|
||||
start_kind: start.start_kind,
|
||||
finish_kind: request.finish_kind.clone(),
|
||||
started_at: start.started_at.clone(),
|
||||
activated_at: if immediate_activation { Some(now.clone()) } else { start.activated_at.clone() },
|
||||
state: state_value.into(),
|
||||
active_mode,
|
||||
restore_zone_enabled,
|
||||
restore_local_thermostat_power: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) } else if immediate_activation { underlying_local_power } else { None },
|
||||
restore_local_thermostat_resume_at: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) } else if immediate_activation { underlying_local_resume_at } else { None },
|
||||
restore_local_thermostat_zone_enabled: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) } else if immediate_activation { underlying_local_zone_enabled } else { None },
|
||||
restore_manual_preset: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) } else if immediate_activation { underlying_manual_preset } else { None },
|
||||
restore_manual_setpoint: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) } else if immediate_activation { underlying_manual_setpoint } else { None },
|
||||
restore_manual_override_until: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_override_until) } else if immediate_activation { underlying_manual_override_until } else { None },
|
||||
expires_at: if immediate_activation && request.finish_kind == "duration" { finish.duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.expires_at },
|
||||
duration_seconds: finish.duration_seconds,
|
||||
safety_duration_seconds: finish.safety_duration_seconds,
|
||||
temperature_target: Some(target.target),
|
||||
temperature_operator: target.is_temperature_condition.then(|| target.temperature_operator.clone()),
|
||||
tolerance_c: target.tolerance,
|
||||
hold_seconds: finish.hold_seconds,
|
||||
condition_started_at: None,
|
||||
condition_last_observed_at: None,
|
||||
paused_at: if zone.device_manual_override && (start.editing_active || starts_now) {
|
||||
existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone()))
|
||||
} else { None },
|
||||
deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()),
|
||||
deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()),
|
||||
deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
|
||||
safety_expires_at: if immediate_activation && target.is_temperature_condition { finish.safety_duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.safety_expires_at },
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result<Zone, AppError> {
|
||||
// A quick preset/setpoint derives its resume boundary from schedules. Take the schedule
|
||||
// lock before the per-zone lock so a concurrent schedule edit cannot leave an override
|
||||
@@ -310,208 +544,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
}
|
||||
}
|
||||
if let Some(request) = patch.temporary_quick_thermostat.as_ref() {
|
||||
let now = Utc::now();
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let existing_session = zone.temporary_quick_thermostat.clone();
|
||||
let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now);
|
||||
let requested_start_kind = request.start_kind.as_str();
|
||||
if !matches!(requested_start_kind, "now" | "delay" | "at") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
|
||||
}
|
||||
|
||||
// Editing an already active session changes only target/finish rules. Its historical
|
||||
// start and activated_at are preserved, so delay/at sessions cannot be accidentally
|
||||
// rescheduled or rejected because their original start is now in the past.
|
||||
let (start_kind, started_at, activated_at) = if editing_active {
|
||||
let existing = existing_session.as_ref().expect("active temporary session must exist");
|
||||
(existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone())
|
||||
} else {
|
||||
let started_at = match requested_start_kind {
|
||||
"now" => now.clone(),
|
||||
"delay" => {
|
||||
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
|
||||
if !(1..=43_200).contains(&minutes) {
|
||||
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
|
||||
}
|
||||
now.clone() + ChronoDuration::minutes(minutes as i64)
|
||||
}
|
||||
"at" => {
|
||||
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
|
||||
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
|
||||
if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
|
||||
at
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
(requested_start_kind.to_string(), started_at, None)
|
||||
};
|
||||
|
||||
let finish_kind = request.finish_kind.as_str();
|
||||
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
|
||||
}
|
||||
|
||||
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
|
||||
if !(8.0..=30.0).contains(&target) {
|
||||
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
|
||||
}
|
||||
let target = (target * 2.0).round() / 2.0;
|
||||
let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() };
|
||||
let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0);
|
||||
let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3));
|
||||
if !(0.1..=3.0).contains(&requested_tolerance) {
|
||||
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
|
||||
}
|
||||
let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance };
|
||||
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
|
||||
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
|
||||
}
|
||||
|
||||
let duration_seconds = if finish_kind == "duration" {
|
||||
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
|
||||
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
|
||||
Some(minutes.saturating_mul(60))
|
||||
} else { None };
|
||||
let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable");
|
||||
let hold_seconds = if finish_kind == "temperature_stable" {
|
||||
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
|
||||
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
|
||||
minutes.saturating_mul(60)
|
||||
} else { 0 };
|
||||
let safety_duration_seconds = if is_temperature_condition {
|
||||
request.max_duration_minutes.map(|minutes| {
|
||||
if !(1..=14_400).contains(&minutes) {
|
||||
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
|
||||
}
|
||||
Ok(minutes.saturating_mul(60))
|
||||
}).transpose()?
|
||||
} else { None };
|
||||
|
||||
let active_base = activated_at.clone().unwrap_or(now.clone());
|
||||
let expires_at = match finish_kind {
|
||||
"duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None },
|
||||
"until" => {
|
||||
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
|
||||
let comparison_start = if editing_active { now.clone() } else { started_at.clone() };
|
||||
if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); }
|
||||
if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
|
||||
Some(until)
|
||||
}
|
||||
"schedule_boundary" => {
|
||||
let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) };
|
||||
Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference)
|
||||
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let safety_expires_at = if editing_active {
|
||||
safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64))
|
||||
} else { None };
|
||||
|
||||
let starts_now = start_kind == "now";
|
||||
let immediate_activation = !editing_active && starts_now && !zone.device_manual_override;
|
||||
let restore_zone_enabled = if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
|
||||
} else if immediate_activation {
|
||||
Some(zone.enabled)
|
||||
} else {
|
||||
// Delayed sessions capture this at actual takeover time (H12), not planning time.
|
||||
None
|
||||
};
|
||||
let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() };
|
||||
let active_mode = if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.active_mode.clone())
|
||||
} else if immediate_activation {
|
||||
Some(captured_mode.clone())
|
||||
} else { None };
|
||||
let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str());
|
||||
if is_temperature_condition {
|
||||
if (condition_mode == "heat" && temperature_operator == "at_or_below")
|
||||
|| (condition_mode == "cool" && temperature_operator == "at_or_above")
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let state_value = if editing_active {
|
||||
if zone.device_manual_override { "paused_manual" } else { "active" }
|
||||
} else if starts_now && zone.device_manual_override {
|
||||
"paused_manual"
|
||||
} else {
|
||||
"scheduled"
|
||||
};
|
||||
let underlying_local_power = zone.local_thermostat_power;
|
||||
let underlying_local_resume_at = zone.local_thermostat_resume_at;
|
||||
let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
|
||||
let underlying_manual_preset = zone.manual_preset.clone();
|
||||
let underlying_manual_setpoint = zone.manual_setpoint;
|
||||
let underlying_manual_override_until = zone.manual_override_until;
|
||||
|
||||
if immediate_activation {
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.enabled = true;
|
||||
zone.manual_setpoint = Some(target);
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.manual_override_until = None;
|
||||
} else if editing_active {
|
||||
// Keep current ownership and update the live target without restarting the session.
|
||||
zone.manual_setpoint = Some(target);
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
|
||||
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
|
||||
start_kind,
|
||||
finish_kind: finish_kind.into(),
|
||||
started_at,
|
||||
activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() },
|
||||
state: state_value.into(),
|
||||
active_mode,
|
||||
restore_zone_enabled,
|
||||
restore_local_thermostat_power: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power)
|
||||
} else if immediate_activation { underlying_local_power } else { None },
|
||||
restore_local_thermostat_resume_at: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at)
|
||||
} else if immediate_activation { underlying_local_resume_at } else { None },
|
||||
restore_local_thermostat_zone_enabled: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled)
|
||||
} else if immediate_activation { underlying_local_zone_enabled } else { None },
|
||||
restore_manual_preset: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone())
|
||||
} else if immediate_activation { underlying_manual_preset } else { None },
|
||||
restore_manual_setpoint: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_manual_setpoint)
|
||||
} else if immediate_activation { underlying_manual_setpoint } else { None },
|
||||
restore_manual_override_until: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_manual_override_until)
|
||||
} else if immediate_activation { underlying_manual_override_until } else { None },
|
||||
expires_at: if immediate_activation && finish_kind == "duration" {
|
||||
duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
|
||||
} else { expires_at },
|
||||
duration_seconds,
|
||||
safety_duration_seconds,
|
||||
temperature_target: Some(target),
|
||||
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
|
||||
tolerance_c: tolerance,
|
||||
hold_seconds,
|
||||
condition_started_at: None,
|
||||
condition_last_observed_at: None,
|
||||
paused_at: if zone.device_manual_override && (editing_active || starts_now) {
|
||||
existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone()))
|
||||
} else { None },
|
||||
deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()),
|
||||
deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()),
|
||||
deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
|
||||
safety_expires_at: if immediate_activation && is_temperature_condition {
|
||||
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
|
||||
} else { safety_expires_at },
|
||||
});
|
||||
apply_temporary_quick_thermostat_request(state, &mut zone, &schedules, request).await?;
|
||||
}
|
||||
if let Some(power) = patch.power {
|
||||
// The neighbouring quick-power control and the explicit Stop button must use the
|
||||
@@ -632,7 +665,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
|
||||
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let house_mode = runtime.house_mode.clone();
|
||||
engine::refresh_control_ownership(&mut zone, true);
|
||||
engine::refresh_control_ownership(&mut zone);
|
||||
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
@@ -53,7 +53,6 @@ impl Config {
|
||||
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
|
||||
discovery_broadcast: self.discovery_broadcast.clone(),
|
||||
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
|
||||
house_power_enabled: true,
|
||||
control_strategy: "setpoint".into(),
|
||||
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
|
||||
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
impl Db {
|
||||
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
|
||||
Ok(ConfigurationExport {
|
||||
format_version: 2,
|
||||
format_version: 3,
|
||||
exported_at: Utc::now(),
|
||||
settings,
|
||||
devices: self.list_devices()?,
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
|
||||
models::{Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
|
||||
state::{AppState, PendingControllerCommand},
|
||||
};
|
||||
|
||||
|
||||
@@ -887,7 +887,7 @@ async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option<
|
||||
zone.control_reason = "Visual Flow automation".into();
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let house_mode = state.settings.read().await.house_mode.clone();
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
if zone.control_owner == "automation" {
|
||||
zone.control_source = "automation.flow".into();
|
||||
zone.control_reason = "Visual Flow automation".into();
|
||||
|
||||
@@ -19,7 +19,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
for mut zone in zones {
|
||||
let device = devices.iter().find(|item| item.id == zone.device_id);
|
||||
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
let configured_effective_mode = configured_effective_mode_owned.as_str();
|
||||
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
|
||||
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
|
||||
|
||||
+153
-163
@@ -1,4 +1,4 @@
|
||||
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
|
||||
fn validate_group_control_patch(patch: &GroupControlPatch) -> Result<Option<f64>, AppError> {
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group mode must be house, cool or heat".into()));
|
||||
@@ -20,14 +20,138 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
return Err(AppError::BadRequest("group setpoint requires preset=custom".into()));
|
||||
}
|
||||
}
|
||||
Ok(patch.setpoint.map(|value| (value * 10.0).round() / 10.0))
|
||||
}
|
||||
|
||||
fn defer_group_climate_change(zone: &mut Zone, patch: &GroupControlPatch, custom_setpoint: Option<f64>) {
|
||||
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return; };
|
||||
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
session.deferred_preset = Some(preset.to_string());
|
||||
if preset != "custom" { session.deferred_setpoint = None; }
|
||||
}
|
||||
if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); }
|
||||
}
|
||||
|
||||
fn apply_group_climate_change(
|
||||
zone: &mut Zone,
|
||||
patch: &GroupControlPatch,
|
||||
custom_setpoint: Option<f64>,
|
||||
manual_group_control: bool,
|
||||
schedules: &[Schedule],
|
||||
) {
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
match mode {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = mode.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
if preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(preset.to_string());
|
||||
if preset != "custom" { zone.manual_setpoint = None; }
|
||||
zone.manual_override_until = if manual_group_control {
|
||||
None
|
||||
} else {
|
||||
next_schedule_boundary_utc(&zone.id, schedules, Local::now())
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(setpoint) = custom_setpoint {
|
||||
zone.setpoint = setpoint;
|
||||
zone.manual_preset = Some("custom".into());
|
||||
zone.manual_setpoint = Some(setpoint);
|
||||
zone.effective_setpoint = Some(setpoint);
|
||||
zone.manual_override_until = if manual_group_control {
|
||||
None
|
||||
} else {
|
||||
next_schedule_boundary_utc(&zone.id, schedules, Local::now())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_group_member_power(
|
||||
zone: &mut Zone,
|
||||
requested_power: Option<bool>,
|
||||
source: &str,
|
||||
temporary_owns_zone: bool,
|
||||
group_handback_at: Option<DateTime<Utc>>,
|
||||
) -> Result<(Option<bool>, bool), Value> {
|
||||
let Some(power) = requested_power else { return Ok((None, false)); };
|
||||
let automatic_power_blocked = source == "automation.group"
|
||||
&& (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone);
|
||||
if automatic_power_blocked {
|
||||
return Err(json!({
|
||||
"scope": "ownership",
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"error": "manual/local thermostat ownership has priority over group automation",
|
||||
}));
|
||||
}
|
||||
if power {
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
return Ok((Some(true), false));
|
||||
}
|
||||
zone.local_thermostat_power = Some(false);
|
||||
zone.local_thermostat_resume_at = group_handback_at;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.effective_mode = "off".into();
|
||||
zone.device_setpoint = None;
|
||||
Ok((Some(false), true))
|
||||
}
|
||||
|
||||
fn update_group_member_ownership(
|
||||
zone: &mut Zone,
|
||||
group: &ClimateGroup,
|
||||
temporary_owns_zone: bool,
|
||||
applied_group_power: Option<bool>,
|
||||
group_handback_at: Option<DateTime<Utc>>,
|
||||
) {
|
||||
if temporary_owns_zone || zone.device_manual_override { return; }
|
||||
if applied_group_power == Some(false) {
|
||||
zone.control_owner = "local_thermostat".into();
|
||||
zone.control_source = "local_thermostat".into();
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_resume_at = group_handback_at.clone();
|
||||
zone.control_reason = if group_handback_at.is_some() {
|
||||
format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)
|
||||
} else {
|
||||
format!("Group {} powered the thermostat off; group ownership released", group.name)
|
||||
};
|
||||
} else if group.power_enabled && zone.local_thermostat_power.is_none() {
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = format!("group:{}", group.id);
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_resume_at = zone.manual_override_until;
|
||||
zone.control_reason = format!("Controlled by group {}", group.name);
|
||||
} else if !group.power_enabled && zone.control_source == format!("group:{}", group.id) {
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_resume_at = zone.manual_override_until;
|
||||
zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
|
||||
let custom_setpoint = validate_group_control_patch(&patch)?;
|
||||
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
|
||||
|
||||
// House/group actions share one ordering domain. This prevents a concurrent group ON
|
||||
// (especially from an automation) from resurrecting the master while whole-house OFF
|
||||
// is being applied. Group and zone locks then make the member update atomic.
|
||||
// from racing with whole-house OFF. Lock order stays house -> cycle -> group -> zone -> device.
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
// Group state participates in thermostat arbitration. Exclude an already-running cycle
|
||||
// while changing group control/profile state so no cycle can act on a stale group snapshot.
|
||||
// Lock order stays house -> cycle -> group -> zone -> device.
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(group_id).await;
|
||||
let mut group = state.db.get_group(group_id)?
|
||||
@@ -36,12 +160,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
locked_zone_ids.sort();
|
||||
locked_zone_ids.dedup();
|
||||
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
|
||||
for zone_id in &locked_zone_ids {
|
||||
_zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
for zone_id in &locked_zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
|
||||
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let custom_setpoint = patch.setpoint.map(|value| (value * 10.0).round() / 10.0);
|
||||
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
|
||||
let resulting_control_enabled = patch.power.unwrap_or(group.power_enabled);
|
||||
if climate_change && !resulting_control_enabled {
|
||||
if source == "automation.group" {
|
||||
@@ -53,38 +174,27 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
"zones": [],
|
||||
"devices": state.db.list_devices()?,
|
||||
"failed": [],
|
||||
"master_power_enabled": true,
|
||||
"suppressed": true,
|
||||
}));
|
||||
}
|
||||
return Err(AppError::BadRequest("enable group control before changing group mode, preset or setpoint".into()));
|
||||
}
|
||||
if let Some(power) = patch.power {
|
||||
group.power_enabled = power;
|
||||
}
|
||||
|
||||
if let Some(power) = patch.power { group.power_enabled = power; }
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
|
||||
// Group power is a scoped bulk power action. OFF powers member thermostats down but
|
||||
// releases group ownership: members are represented as individually OFF, not as
|
||||
// "blocked by a disabled group". ON clears that scoped OFF and lets group arbitration
|
||||
// take ownership again. The whole-house master remains independent.
|
||||
let manual_group_control = source != "automation.group";
|
||||
// A manual Group OFF is a temporary bulk pause. All members receive the same
|
||||
// 15-minute hand-back deadline, so their thermostat automation resumes together.
|
||||
// Automation-driven group OFF remains persistent until another automation/user action.
|
||||
let group_handback_at = if manual_group_control && patch.power == Some(false) {
|
||||
Some(group.updated_at.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let explicit_group_power = patch.power.is_some() && manual_group_control;
|
||||
// Applying a manual mode/profile/custom target to an already-enabled group is itself an
|
||||
// explicit scoped takeover. It must wake members that were left locally OFF by a previous
|
||||
// group/whole-house OFF; otherwise the UI says "group control" while every unit stays OFF.
|
||||
let manual_group_activation = manual_group_control && group.power_enabled && climate_change;
|
||||
let explicit_group_takeover = explicit_group_power || manual_group_activation;
|
||||
let requested_member_power = patch.power.or(manual_group_activation.then_some(true));
|
||||
|
||||
let mut zones = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
@@ -93,15 +203,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
|
||||
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
||||
if patch.power.is_some() || climate_change {
|
||||
rearm_compressor_queue(&mut zone);
|
||||
}
|
||||
if patch.power.is_some() || climate_change { rearm_compressor_queue(&mut zone); }
|
||||
let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
|
||||
|
||||
// A user/HA group power click is an explicit scoped takeover. End any older direct
|
||||
// or temporary ownership so all members react consistently to the bulk command.
|
||||
// Scheduled automations deliberately do not do this: manual/local ownership keeps
|
||||
// its higher priority and the automation can only affect currently free members.
|
||||
if explicit_group_takeover {
|
||||
if zone.temporary_quick_thermostat.is_some() {
|
||||
if temporary_owns_zone {
|
||||
@@ -111,158 +215,47 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
}
|
||||
temporary_owns_zone = false;
|
||||
}
|
||||
if zone.device_manual_override {
|
||||
reset_device_manual_override(&mut zone);
|
||||
}
|
||||
if zone.device_manual_override { reset_device_manual_override(&mut zone); }
|
||||
}
|
||||
|
||||
if temporary_owns_zone {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
session.deferred_preset = Some(preset.to_string());
|
||||
if preset != "custom" { session.deferred_setpoint = None; }
|
||||
}
|
||||
if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); }
|
||||
}
|
||||
defer_group_climate_change(&mut zone, &patch, custom_setpoint);
|
||||
if climate_change {
|
||||
state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({
|
||||
"zone_id": zone.id, "group_id": group.id, "source": source
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
match mode {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = mode.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
if preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(preset.to_string());
|
||||
if preset != "custom" { zone.manual_setpoint = None; }
|
||||
zone.manual_override_until = if manual_group_control {
|
||||
None
|
||||
} else {
|
||||
next_schedule_boundary_utc(&zone.id, &schedules, Local::now())
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(setpoint) = custom_setpoint {
|
||||
zone.setpoint = setpoint;
|
||||
zone.manual_preset = Some("custom".into());
|
||||
zone.manual_setpoint = Some(setpoint);
|
||||
zone.effective_setpoint = Some(setpoint);
|
||||
zone.manual_override_until = if manual_group_control {
|
||||
None
|
||||
} else {
|
||||
next_schedule_boundary_utc(&zone.id, &schedules, Local::now())
|
||||
};
|
||||
}
|
||||
}
|
||||
// Group power OFF is represented on every member as a local thermostat OFF so the
|
||||
// physical unit is stopped immediately and group ownership is released. A manual Group
|
||||
// OFF gets one synchronized 15-minute hand-back deadline for all members; automation
|
||||
// Group OFF is persistent. Power ON clears the scoped OFF immediately.
|
||||
let mut force_power_off_after_save = false;
|
||||
let mut applied_group_power: Option<bool> = None;
|
||||
let requested_member_power = patch.power.or(manual_group_activation.then_some(true));
|
||||
if let Some(power) = requested_member_power {
|
||||
let automatic_power_blocked = source == "automation.group"
|
||||
&& (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone);
|
||||
if automatic_power_blocked {
|
||||
failed.push(json!({
|
||||
"scope": "ownership",
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"error": "manual/local thermostat ownership has priority over group automation",
|
||||
}));
|
||||
} else if power {
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
applied_group_power = Some(true);
|
||||
} else {
|
||||
zone.local_thermostat_power = Some(false);
|
||||
zone.local_thermostat_resume_at = group_handback_at.clone();
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.effective_mode = "off".into();
|
||||
zone.device_setpoint = None;
|
||||
force_power_off_after_save = forced_off_devices.insert(zone.device_id.clone());
|
||||
applied_group_power = Some(false);
|
||||
}
|
||||
apply_group_climate_change(&mut zone, &patch, custom_setpoint, manual_group_control, &schedules);
|
||||
}
|
||||
|
||||
if !temporary_owns_zone && !zone.device_manual_override {
|
||||
if applied_group_power == Some(false) {
|
||||
zone.control_owner = "local_thermostat".into();
|
||||
zone.control_source = "local_thermostat".into();
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_resume_at = group_handback_at.clone();
|
||||
zone.control_reason = if group_handback_at.is_some() {
|
||||
format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)
|
||||
} else {
|
||||
format!("Group {} powered the thermostat off; group ownership released", group.name)
|
||||
};
|
||||
} else if group.power_enabled && zone.local_thermostat_power.is_none() {
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = format!("group:{}", group.id);
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_resume_at = zone.manual_override_until;
|
||||
zone.control_reason = format!("Controlled by group {}", group.name);
|
||||
} else if !group.power_enabled && zone.control_source == format!("group:{}", group.id) {
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = Some(Utc::now());
|
||||
zone.control_resume_at = zone.manual_override_until;
|
||||
zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name);
|
||||
}
|
||||
}
|
||||
let (applied_group_power, should_force_off) = match apply_group_member_power(
|
||||
&mut zone, requested_member_power, source, temporary_owns_zone, group_handback_at.clone(),
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(error) => { failed.push(error); (None, false) }
|
||||
};
|
||||
update_group_member_ownership(&mut zone, &group, temporary_owns_zone, applied_group_power, group_handback_at);
|
||||
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
if force_power_off_after_save {
|
||||
if should_force_off && forced_off_devices.insert(zone.device_id.clone()) {
|
||||
if let Err(err) = force_power_off_device_locked(state, &zone.device_id).await {
|
||||
state.log("error", "group.power_off_error", &err.to_string(), json!({
|
||||
"group_id": group.id,
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"source": source,
|
||||
"group_id": group.id, "zone_id": zone.id, "device_id": zone.device_id, "source": source,
|
||||
}));
|
||||
failed.push(json!({
|
||||
"scope": "device",
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"error": err.to_string(),
|
||||
"scope": "device", "zone_id": zone.id, "device_id": zone.device_id, "error": err.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
zones.push(zone);
|
||||
}
|
||||
|
||||
let master_power_enabled = true;
|
||||
let control_toggled_on = patch.power == Some(true);
|
||||
let control_enabled = group.power_enabled;
|
||||
|
||||
// OFF is already a forced, per-member physical transition performed under zone -> device
|
||||
// locks above. ON/profile/setpoint changes use the thermostat arbiter so target, hysteresis,
|
||||
// compressor protection and mode-change safety are applied consistently before returning.
|
||||
let run_immediately = control_toggled_on || (control_enabled && climate_change);
|
||||
let run_immediately = patch.power == Some(true) || (group.power_enabled && climate_change);
|
||||
let zones = if run_immediately {
|
||||
// Do not just wake the background loop: a group ON/profile/setpoint action is expected
|
||||
// to arbitrate every member before the HTTP request completes. Release member/domain
|
||||
// locks first, then run one globally serialized thermostat cycle.
|
||||
drop(_zone_guards);
|
||||
drop(_group_guard);
|
||||
drop(_cycle_guard);
|
||||
@@ -279,16 +272,13 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
};
|
||||
|
||||
state.log("info", source, &format!("Updated group {}", group.name), json!({
|
||||
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "setpoint": custom_setpoint,
|
||||
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
|
||||
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
|
||||
"setpoint": custom_setpoint, "zones": zones.len(), "failed": failed.len(),
|
||||
}));
|
||||
Ok(json!({
|
||||
"group": group,
|
||||
"zones": zones,
|
||||
"devices": state.db.list_devices()?,
|
||||
"failed": failed,
|
||||
"master_power_enabled": master_power_enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) {
|
||||
pub fn refresh_control_ownership(zone: &mut Zone) {
|
||||
let now = Utc::now();
|
||||
let (owner, source, resume_at, reason) = if zone.device_manual_override {
|
||||
let source = match zone.control_source.as_str() {
|
||||
|
||||
@@ -164,7 +164,6 @@ async fn activate_due_temporary_quick_thermostats(
|
||||
zones: &mut [Zone],
|
||||
schedules: &[Schedule],
|
||||
house_mode: &str,
|
||||
house_power_enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
for zone in zones.iter_mut() {
|
||||
@@ -184,10 +183,6 @@ async fn activate_due_temporary_quick_thermostats(
|
||||
continue;
|
||||
}
|
||||
if started_at > now { continue; }
|
||||
if !house_power_enabled {
|
||||
set_temporary_wait_state(state, zone, "waiting_master", now)?;
|
||||
continue;
|
||||
}
|
||||
if zone.device_manual_override {
|
||||
set_temporary_wait_state(state, zone, "paused_manual", now)?;
|
||||
continue;
|
||||
|
||||
+5
-5
@@ -552,7 +552,7 @@ mod tests {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.control_source = "web_thermostat".into();
|
||||
refresh_control_ownership(&mut zone, false);
|
||||
refresh_control_ownership(&mut zone);
|
||||
assert_eq!(zone.control_owner, "local_thermostat");
|
||||
assert_ne!(zone.control_source, "global");
|
||||
}
|
||||
@@ -760,7 +760,7 @@ mod tests {
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
|
||||
zone.control_source = "automation.device".into();
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
assert_eq!(zone.control_owner, "automation");
|
||||
assert_eq!(zone.control_source, "automation.device");
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
@@ -797,7 +797,7 @@ mod tests {
|
||||
set_local_thermostat_power(&mut zone, false, Utc::now());
|
||||
|
||||
assert!(reset_local_thermostat_override(&mut zone));
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
||||
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
@@ -910,7 +910,7 @@ mod tests {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.control_source = "web_thermostat".into();
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
let owner = zone.control_owner.clone();
|
||||
let source = zone.control_source.clone();
|
||||
let until = Utc::now() + chrono::Duration::seconds(180);
|
||||
@@ -985,7 +985,7 @@ mod tests {
|
||||
assert!(set_house_bulk_thermostat_power(&mut zone, true));
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert_eq!(zone.control_source, "house_power");
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
assert_eq!(zone.control_owner, "automation");
|
||||
assert_eq!(zone.control_source, "house_power");
|
||||
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
|
||||
|
||||
+276
-258
@@ -30,77 +30,292 @@ pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
|
||||
clear_compressor_pending(zone, true);
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut zone_snapshot = state.db.list_zones()?;
|
||||
// Local/temporary thermostat ownership is independent from the legacy whole-house master gate
|
||||
// commands. Expire/activate sessions on their own deadlines.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, true).await?;
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
||||
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
|
||||
let device_snapshot = state.db.list_devices()?;
|
||||
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
|
||||
let resolved_outdoor = if configured_outdoor.is_empty() {
|
||||
async fn resolve_cycle_outdoor_temperature(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
devices: &[Device],
|
||||
) -> Option<f64> {
|
||||
let configured = settings.home_assistant.outdoor_entity_id.trim();
|
||||
let resolved = if configured.is_empty() {
|
||||
None
|
||||
} else {
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured))
|
||||
};
|
||||
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await {
|
||||
let from_home_assistant = if let Some(entity_id) = resolved.as_deref() {
|
||||
match home_assistant::read_temperature(
|
||||
&state.http,
|
||||
&settings.home_assistant,
|
||||
Some(entity_id),
|
||||
Some(settings.home_assistant.sensor_stale_after_seconds),
|
||||
).await {
|
||||
Ok(value) => {
|
||||
record_ha_history(
|
||||
state,
|
||||
entity_id,
|
||||
None,
|
||||
"outdoor",
|
||||
value,
|
||||
settings.poll_interval_seconds,
|
||||
);
|
||||
record_ha_history(state, entity_id, None, "outdoor", value, settings.poll_interval_seconds);
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
|
||||
tracing::debug!(configured_entity=%configured, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
|
||||
{
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != outdoor_temperature {
|
||||
*current = outdoor_temperature;
|
||||
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
|
||||
}
|
||||
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices));
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != temperature {
|
||||
*current = temperature;
|
||||
state.broadcast("outdoor.updated", json!({"temperature": temperature}));
|
||||
}
|
||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||
temperature
|
||||
}
|
||||
|
||||
// Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost
|
||||
// one request timeout per cycle, not one timeout multiplied by the number of zones.
|
||||
let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| {
|
||||
async fn read_cycle_room_sensors(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
zones: &[Zone],
|
||||
) -> HashMap<String, (Option<String>, Result<f64, String>)> {
|
||||
futures_util::future::join_all(zones.iter().filter_map(|zone| {
|
||||
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; }
|
||||
let zone_id = zone.id.clone();
|
||||
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
|
||||
let http = &state.http;
|
||||
let ha_settings = &settings.home_assistant;
|
||||
let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds);
|
||||
let stale_after_seconds = effective_sensor_stale_after_seconds(
|
||||
zone.sensor_stale_after_seconds,
|
||||
ha_settings.sensor_stale_after_seconds,
|
||||
);
|
||||
Some(async move {
|
||||
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await
|
||||
.map_err(|err| err.to_string());
|
||||
(zone_id, resolved_entity, result)
|
||||
})
|
||||
})).await;
|
||||
let mut room_sensor_results: HashMap<String, (Option<String>, Result<f64, String>)> = room_sensor_reads.into_iter()
|
||||
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
|
||||
.collect();
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn refresh_zone_temperature(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
zone: &mut Zone,
|
||||
device: &Device,
|
||||
room_sensor_results: &mut HashMap<String, (Option<String>, Result<f64, String>)>,
|
||||
) -> (String, bool) {
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
|
||||
device.current_temperature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match room_sensor_results.remove(&zone.id) {
|
||||
Some((resolved_entity, Ok(value))) => {
|
||||
if let Some(entity_id) = resolved_entity.as_deref() {
|
||||
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
Some((resolved_entity, Err(err))) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
let kind = if err.contains("Home Assistant sensor is stale:") { "ha.sensor_stale" } else { "ha.sensor_error" };
|
||||
state.log("warn", kind, &err, json!({
|
||||
"zone_id": zone.id,
|
||||
"configured_entity_id": zone.ha_entity_id.as_deref(),
|
||||
"resolved_entity_id": resolved_entity,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature);
|
||||
zone.device_temperature = device_temperature;
|
||||
zone.external_temperature = external_temperature;
|
||||
zone.current_temperature = temperature;
|
||||
zone.control_temperature_source = source;
|
||||
zone.updated_at = Utc::now();
|
||||
(previous_source, discrepancy)
|
||||
}
|
||||
|
||||
fn persist_zone_cycle_with_history(
|
||||
state: &AppState,
|
||||
zone: &Zone,
|
||||
cycle_started_at: DateTime<Utc>,
|
||||
outdoor_temperature: Option<f64>,
|
||||
poll_interval_seconds: u64,
|
||||
) -> Result<Zone> {
|
||||
record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds);
|
||||
let persisted = persist_zone_cycle(state, zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted)?);
|
||||
Ok(persisted)
|
||||
}
|
||||
|
||||
async fn handle_zone_pre_control_state(
|
||||
state: &AppState,
|
||||
settings: &RuntimeSettings,
|
||||
schedules: &[Schedule],
|
||||
temporary_restored_disabled: &[String],
|
||||
zone: &mut Zone,
|
||||
device: &Device,
|
||||
effective_mode: &str,
|
||||
cycle_started_at: DateTime<Utc>,
|
||||
outdoor_temperature: Option<f64>,
|
||||
) -> Result<bool> {
|
||||
// A queued whole-house ON is a delayed bulk physical action, not thermostat ownership.
|
||||
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
|
||||
let now = Utc::now();
|
||||
if device.power {
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.updated_at = now;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
let due = !settings.compressor_protection_enabled
|
||||
|| zone.compressor_pending_until.as_ref().map(|until| until <= &now).unwrap_or(true);
|
||||
if due {
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
|
||||
Ok(updated_device) => {
|
||||
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
|
||||
zone.lockout_until = zone.compressor_pending_until.clone();
|
||||
zone.lockout_reason = Some("global_start_retry".into());
|
||||
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if !zone.enabled {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, zone, device).await;
|
||||
}
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if !device.enabled {
|
||||
clear_compressor_pending(zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if zone.device_manual_override {
|
||||
clear_compressor_pending(zone, true);
|
||||
let temporary_active = temporary_quick_thermostat_is_active(zone, zone.updated_at.clone());
|
||||
let pause_started_at = zone.updated_at.clone();
|
||||
if temporary_active {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
|
||||
session.state = "paused_manual".into();
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
}
|
||||
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
|
||||
let active_schedule = active_schedule_for_zone(zone, schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(zone, active_schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let condition_sample_at = match zone.control_temperature_source.as_str() {
|
||||
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
|
||||
_ => device.last_seen.clone(),
|
||||
};
|
||||
let max_condition_gap_seconds = settings.poll_interval_seconds
|
||||
.max(settings.zone_interval_seconds)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(5);
|
||||
let condition_now = zone.updated_at.clone();
|
||||
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
|
||||
zone,
|
||||
condition_now,
|
||||
condition_sample_at,
|
||||
max_condition_gap_seconds,
|
||||
) {
|
||||
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
||||
finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode);
|
||||
let persisted = persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &persisted, device).await;
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
|
||||
}));
|
||||
state.wake_zone_control();
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
if zone.local_thermostat_power == Some(false) {
|
||||
zone.effective_mode = "off".into();
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
if device.online && device.communication_failures == 0 && device.power {
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
let latest = state.db.get_zone(&zone.id)?;
|
||||
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
|
||||
if let Err(err) = send_command_locked(
|
||||
state,
|
||||
&zone.device_id,
|
||||
DeviceCommand { power: Some(false), ..Default::default() },
|
||||
).await {
|
||||
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut zone_snapshot = state.db.list_zones()?;
|
||||
// Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
|
||||
let device_snapshot = state.db.list_devices()?;
|
||||
let outdoor_temperature = resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await;
|
||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||
let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await;
|
||||
|
||||
for zone_snapshot_item in zone_snapshot {
|
||||
// Every thermostat decision participates in the same zone -> device ordering as
|
||||
@@ -108,7 +323,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// interactive change cannot be evaluated from a stale snapshot.
|
||||
let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await;
|
||||
let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; };
|
||||
let cycle_started_at = zone.updated_at;
|
||||
let cycle_started_at = zone.updated_at.clone();
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
@@ -119,8 +334,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.control_reason = "Group override expired at schedule boundary".into();
|
||||
}
|
||||
}
|
||||
// v0.8.20 makes direct/manual takeover persistent. Normalize any legacy persisted
|
||||
// boundary from older releases instead of silently returning ownership to schedules.
|
||||
// Direct/manual takeover stays active until the user explicitly resumes automation.
|
||||
if zone.device_manual_override && zone.device_manual_override_until.is_some() {
|
||||
zone.device_manual_override_until = None;
|
||||
zone.control_resume_at = None;
|
||||
@@ -142,220 +356,24 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// persistent gate: later thermostat/group/manual intent may act independently.
|
||||
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
||||
zone.effective_mode = effective_mode_owned.clone();
|
||||
refresh_control_ownership(&mut zone, true);
|
||||
refresh_control_ownership(&mut zone);
|
||||
let effective_mode = effective_mode_owned.as_str();
|
||||
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
// Never feed the thermostat a cached GREE temperature after any communication
|
||||
// failure. External HA sensors may still keep a zone operational when configured.
|
||||
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
|
||||
device.current_temperature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match room_sensor_results.remove(&zone.id) {
|
||||
Some((resolved_entity, Ok(value))) => {
|
||||
if let Some(entity_id) = resolved_entity.as_deref() {
|
||||
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
Some((resolved_entity, Err(err))) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
let notification_kind = if err.contains("Home Assistant sensor is stale:") {
|
||||
"ha.sensor_stale"
|
||||
} else {
|
||||
"ha.sensor_error"
|
||||
};
|
||||
state.log("warn", notification_kind, &err, json!({
|
||||
"zone_id": zone.id,
|
||||
"configured_entity_id": zone.ha_entity_id.as_deref(),
|
||||
"resolved_entity_id": resolved_entity,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (previous_source, discrepancy) = refresh_zone_temperature(
|
||||
state, &settings, &mut zone, &device, &mut room_sensor_results,
|
||||
);
|
||||
|
||||
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
|
||||
zone.device_temperature = device_temperature;
|
||||
zone.external_temperature = external_temperature;
|
||||
zone.current_temperature = temperature;
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
// A queued whole-house ON is a delayed bulk physical action, not thermostat
|
||||
// ownership. It must survive local/group/manual state while compressor protection is
|
||||
// active, then execute once and hand control straight back to the existing owner.
|
||||
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
|
||||
let now = Utc::now();
|
||||
if device.power {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.updated_at = now;
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
let due = !settings.compressor_protection_enabled
|
||||
|| zone.compressor_pending_until.map(|until| until <= now).unwrap_or(true);
|
||||
if due {
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
|
||||
Ok(updated_device) => {
|
||||
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
// Keep the user-visible task and retry on a bounded deadline instead of
|
||||
// spinning immediately or silently dropping the requested global start.
|
||||
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
|
||||
zone.lockout_until = zone.compressor_pending_until;
|
||||
zone.lockout_reason = Some("global_start_retry".into());
|
||||
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A disabled thermostat zone is completely outside normal controller ownership.
|
||||
// Keep its sensors fresh, but do not let group state, schedules or thermostat
|
||||
// modulation touch the unit. Manual control from the technical Devices view may
|
||||
// therefore remain active until the zone is explicitly enabled again.
|
||||
if !zone.enabled {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
|
||||
}
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A technically disabled device is outside thermostat ownership. Do not create
|
||||
// repeated command errors while keeping any available external sensor data visible.
|
||||
if !device.enabled {
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A physical/manual takeover has higher priority than thermostat, schedule, group and
|
||||
// automation control. Continue sensor/history updates, but reflect the unit's real state
|
||||
// instead of sending corrective frames that would fight the person holding the remote.
|
||||
if zone.device_manual_override {
|
||||
// Direct/manual ownership and the thermostat compressor queue are mutually
|
||||
// exclusive. Clean any stale persisted task before remaining passive.
|
||||
clear_compressor_pending(&mut zone, true);
|
||||
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
|
||||
// selected profile/target. Keep the intended target visible and report the physical
|
||||
// unit target separately through device_setpoint. This makes Resume/Profile actions
|
||||
// deterministic and avoids a standby device target (for example 25 C) masquerading
|
||||
// as the zone's Sleep/Comfort target.
|
||||
let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone());
|
||||
let pause_started_at = zone.updated_at;
|
||||
if temporary_active {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
|
||||
session.state = "paused_manual".into();
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
}
|
||||
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
// Keep effective_mode's existing meaning during takeover: it reflects the physical
|
||||
// unit, while effective_setpoint above remains the thermostat intent.
|
||||
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Temperature completion belongs to the temporary thermostat only while it truly owns
|
||||
// the zone. A manual/device takeover above therefore pauses the hold instead of silently
|
||||
// consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this
|
||||
// control cycle. A long gap resets continuous-hold evidence after restart/stale sensors.
|
||||
let condition_sample_at = match zone.control_temperature_source.as_str() {
|
||||
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
|
||||
_ => device.last_seen.clone(),
|
||||
};
|
||||
let max_condition_gap_seconds = settings.poll_interval_seconds
|
||||
.max(settings.zone_interval_seconds)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(5);
|
||||
let condition_now = zone.updated_at.clone();
|
||||
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
|
||||
if handle_zone_pre_control_state(
|
||||
state,
|
||||
&settings,
|
||||
&schedules,
|
||||
&temporary_restored_disabled,
|
||||
&mut zone,
|
||||
condition_now,
|
||||
condition_sample_at,
|
||||
max_condition_gap_seconds,
|
||||
) {
|
||||
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
||||
finish_temporary_quick_thermostat(&mut zone, &schedules, &settings.house_mode);
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await;
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
|
||||
}));
|
||||
state.wake_zone_control();
|
||||
continue;
|
||||
}
|
||||
|
||||
if zone.local_thermostat_power == Some(false) {
|
||||
zone.effective_mode = "off".into();
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
if device.online && device.communication_failures == 0 && device.power {
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
let latest = state.db.get_zone(&zone.id)?;
|
||||
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
|
||||
if let Err(err) = send_command_locked(
|
||||
state,
|
||||
&zone.device_id,
|
||||
DeviceCommand { power: Some(false), ..Default::default() },
|
||||
).await {
|
||||
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
&device,
|
||||
effective_mode,
|
||||
cycle_started_at.clone(),
|
||||
outdoor_temperature,
|
||||
).await? {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ async fn main() -> Result<()> {
|
||||
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
|
||||
}
|
||||
config.apply_runtime_env_overrides(&mut runtime_settings);
|
||||
// v0.9.4: whole-house ON/OFF is a bulk thermostat action, not a persistent master gate.
|
||||
// Normalize databases upgraded from older releases so a historical OFF cannot suppress
|
||||
// thermostats, groups or automations after restart.
|
||||
runtime_settings.house_power_enabled = true;
|
||||
db.save_runtime_settings(&runtime_settings)?;
|
||||
|
||||
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
|
||||
|
||||
@@ -12,5 +12,6 @@ include!("models/flow.rs");
|
||||
include!("models/automation.rs");
|
||||
include!("models/history.rs");
|
||||
include!("models/integrations.rs");
|
||||
include!("models/settings_api.rs");
|
||||
include!("models/control_plan.rs");
|
||||
include!("models/runtime.rs");
|
||||
|
||||
@@ -9,10 +9,6 @@ pub struct RuntimeSettings {
|
||||
/// 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,
|
||||
/// Legacy compatibility flag. Since v0.9.4 global ON/OFF is a bulk thermostat action, not an
|
||||
/// automation gate. The controller normalizes this field to true on load/import.
|
||||
#[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,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApplicationSettings {
|
||||
pub simulator_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GreeSettings {
|
||||
pub controller_id: String,
|
||||
pub poll_interval_seconds: u64,
|
||||
pub zone_interval_seconds: u64,
|
||||
pub discovery_timeout_ms: u64,
|
||||
pub discovery_broadcast: String,
|
||||
pub suppress_device_beep: bool,
|
||||
pub compressor_protection_enabled: bool,
|
||||
pub compressor_protection_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HistorySettings {
|
||||
pub retention_days: u32,
|
||||
pub compaction_enabled: bool,
|
||||
pub event_retention_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InfluxDbSettingsUpdate {
|
||||
pub enabled: bool,
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
pub database: String,
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
pub org: String,
|
||||
pub bucket: String,
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
pub history_threshold_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InfluxDbSettingsView {
|
||||
pub enabled: bool,
|
||||
pub version: String,
|
||||
pub url: String,
|
||||
pub database: String,
|
||||
pub username: String,
|
||||
pub password_configured: bool,
|
||||
pub org: String,
|
||||
pub bucket: String,
|
||||
pub token_configured: bool,
|
||||
pub history_threshold_days: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSettingsUpdate {
|
||||
pub enabled: bool,
|
||||
pub mode: String,
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub pushover_app_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pushover_user_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub slack_webhook_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub discord_webhook_url: Option<String>,
|
||||
pub cooldown_seconds: u64,
|
||||
pub communication_failure_threshold: u32,
|
||||
pub target_timeout_minutes: u32,
|
||||
pub alert_types: NotificationAlertTypes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSettingsView {
|
||||
pub enabled: bool,
|
||||
pub mode: String,
|
||||
pub provider: String,
|
||||
pub pushover_configured: bool,
|
||||
pub slack_configured: bool,
|
||||
pub discord_configured: bool,
|
||||
pub cooldown_seconds: u64,
|
||||
pub communication_failure_threshold: u32,
|
||||
pub target_timeout_minutes: u32,
|
||||
pub alert_types: NotificationAlertTypes,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HomeAssistantSettingsUpdate {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub token: Option<String>,
|
||||
pub default_entity_id: String,
|
||||
pub outdoor_entity_id: String,
|
||||
pub sensor_stale_after_seconds: u64,
|
||||
pub allow_invalid_tls: bool,
|
||||
pub sensor_aliases: BTreeMap<String, String>,
|
||||
pub flow_inputs: Vec<FlowSharedInput>,
|
||||
pub outdoor_assist_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HomeAssistantSettingsView {
|
||||
pub url: String,
|
||||
pub token_configured: bool,
|
||||
pub default_entity_id: String,
|
||||
pub outdoor_entity_id: String,
|
||||
pub sensor_stale_after_seconds: u64,
|
||||
pub allow_invalid_tls: bool,
|
||||
pub sensor_aliases: BTreeMap<String, String>,
|
||||
pub flow_inputs: Vec<FlowSharedInput>,
|
||||
pub outdoor_assist_enabled: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user