931 lines
35 KiB
Rust
931 lines
35 KiB
Rust
struct ConfigurationIds<'a> {
|
|
devices: std::collections::HashSet<&'a str>,
|
|
zones: std::collections::HashSet<&'a str>,
|
|
device_groups: 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);
|
|
// Cloud credentials are account-scoped secrets and must never be returned to the frontend,
|
|
// including configuration exports. Import can reuse the already configured password on the
|
|
// target controller when account/region match.
|
|
export.settings.gree_cloud.password.clear();
|
|
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.14.2".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(),
|
|
device_groups: export.device_groups.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.device_groups.len() != export.device_groups.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.device_groups.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> {
|
|
// The same physical unit may intentionally exist once as Local and once as GREE Cloud.
|
|
// Reject duplicates only within the same explicit transport.
|
|
let device_transport_macs: std::collections::HashSet<(ConnectionType, &str)> = export
|
|
.devices
|
|
.iter()
|
|
.map(|item| (item.connection_type, item.mac.as_str()))
|
|
.collect();
|
|
if device_transport_macs.len() != export.devices.len() {
|
|
return Err(AppError::BadRequest(
|
|
"import contains duplicate device MAC addresses for the same connection type".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(),
|
|
));
|
|
}
|
|
}
|
|
let mut installation_devices = std::collections::HashSet::new();
|
|
for group in &export.device_groups {
|
|
if group.name.trim().is_empty() || group.device_ids.is_empty() {
|
|
return Err(AppError::BadRequest(
|
|
"import contains an empty split/multisplit installation".into(),
|
|
));
|
|
}
|
|
if group.kind == DeviceGroupKind::Split && group.device_ids.len() != 1 {
|
|
return Err(AppError::BadRequest(
|
|
"import contains a split installation with more than one unit".into(),
|
|
));
|
|
}
|
|
for device_id in &group.device_ids {
|
|
if !ids.devices.contains(device_id.as_str()) {
|
|
return Err(AppError::BadRequest(
|
|
"import contains an installation referencing a missing device".into(),
|
|
));
|
|
}
|
|
if !installation_devices.insert(device_id.as_str()) {
|
|
return Err(AppError::BadRequest(
|
|
"import assigns one device to more than one split/multisplit installation".into(),
|
|
));
|
|
}
|
|
}
|
|
if group.energy_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
|
|
return Err(AppError::BadRequest(
|
|
"import contains an installation energy source outside the installation".into(),
|
|
));
|
|
}
|
|
if group.energy_source == EnergySourcePreference::GreeCloud && group.energy_device_id.is_none() {
|
|
return Err(AppError::BadRequest(
|
|
"import contains a GREE Cloud installation without an energy source device".into(),
|
|
));
|
|
}
|
|
if group.energy_source == EnergySourcePreference::HomeAssistant && group.ha_energy_entity_id.as_deref().map_or(true, |value| value.trim().is_empty()) {
|
|
return Err(AppError::BadRequest(
|
|
"import contains a Home Assistant installation without an energy entity".into(),
|
|
));
|
|
}
|
|
if let Some(energy_device_id) = group.energy_device_id.as_deref() {
|
|
let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| {
|
|
AppError::BadRequest("import contains an installation energy source referencing a missing device".into())
|
|
})?;
|
|
if energy_device.connection_type != ConnectionType::GreeCloud || !energy_device.capabilities.energy_meter {
|
|
return Err(AppError::BadRequest(
|
|
"import contains an installation energy source without a GREE Cloud energy meter".into(),
|
|
));
|
|
}
|
|
}
|
|
if group.ha_energy_entity_id.as_deref().is_some_and(|value| !value.trim().is_empty()) {
|
|
if group.ha_energy_device_class.as_deref() != Some("energy")
|
|
|| !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing"))
|
|
|| !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh"))
|
|
{
|
|
return Err(AppError::BadRequest(
|
|
"import contains an invalid Home Assistant cumulative energy entity".into(),
|
|
));
|
|
}
|
|
}
|
|
if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
|
|
return Err(AppError::BadRequest(
|
|
"import contains an installation outdoor-temperature source outside the installation".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.ping_metrics_enabled = gree.ping_metrics_enabled;
|
|
settings.ping_interval_seconds = gree.ping_interval_seconds;
|
|
settings.ping_sample_count = gree.ping_sample_count;
|
|
settings.suppress_device_beep = gree.suppress_device_beep;
|
|
settings.compressor_protection_enabled = gree.compressor_protection_enabled;
|
|
settings.compressor_protection_seconds = gree.compressor_protection_seconds;
|
|
|
|
if crate::protocol::gree_cloud::region_base_url(settings.gree_cloud.region.trim()).is_none() {
|
|
return Err(AppError::BadRequest("unsupported GREE Cloud region".into()));
|
|
}
|
|
settings.gree_cloud.polling_interval_seconds =
|
|
settings.gree_cloud.polling_interval_seconds.clamp(30, 3600);
|
|
settings.gree_cloud.connectivity_metrics_interval_seconds =
|
|
settings.gree_cloud.connectivity_metrics_interval_seconds.clamp(30, 3600);
|
|
settings.gree_cloud.connectivity_metrics_sample_count =
|
|
settings.gree_cloud.connectivity_metrics_sample_count.clamp(1, 10);
|
|
if settings.gree_cloud.account_id.trim().is_empty() {
|
|
settings.gree_cloud.account_id = "default".into();
|
|
}
|
|
|
|
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 hydrate_imported_cloud_device_keys(
|
|
state: &AppState,
|
|
export: &mut ConfigurationExport,
|
|
) -> Result<(), AppError> {
|
|
if export.settings.gree_cloud.password.trim().is_empty() {
|
|
let current = state.settings.read().await.gree_cloud.clone();
|
|
if current.region.eq_ignore_ascii_case(&export.settings.gree_cloud.region)
|
|
&& current.username.eq_ignore_ascii_case(&export.settings.gree_cloud.username)
|
|
&& !current.password.trim().is_empty()
|
|
{
|
|
export.settings.gree_cloud.password = current.password;
|
|
}
|
|
}
|
|
let needs_cloud_keys = export.devices.iter().any(|device| {
|
|
device.connection_type == ConnectionType::GreeCloud
|
|
&& device.key.as_deref().unwrap_or_default().is_empty()
|
|
});
|
|
if !needs_cloud_keys {
|
|
return Ok(());
|
|
}
|
|
|
|
let settings = &export.settings.gree_cloud;
|
|
let mut api = crate::protocol::gree_cloud::GreeCloudApi::for_region(
|
|
state.http.clone(),
|
|
&settings.region,
|
|
&settings.username,
|
|
&settings.password,
|
|
)
|
|
.map_err(|err| AppError::BadRequest(format!(
|
|
"cannot restore GREE Cloud device secrets from account: {err}"
|
|
)))?;
|
|
api.login().await.map_err(|err| {
|
|
AppError::BadRequest(format!(
|
|
"cannot restore GREE Cloud device secrets: account login failed: {err}"
|
|
))
|
|
})?;
|
|
let discovered = api.get_all_devices().await.map_err(|err| {
|
|
AppError::BadRequest(format!(
|
|
"cannot restore GREE Cloud device secrets: discovery failed: {err}"
|
|
))
|
|
})?;
|
|
|
|
for device in export
|
|
.devices
|
|
.iter_mut()
|
|
.filter(|device| device.connection_type == ConnectionType::GreeCloud)
|
|
{
|
|
if device.key.as_deref().is_some_and(|key| !key.is_empty()) {
|
|
continue;
|
|
}
|
|
let cloud_id = device.cloud_device_id.as_deref().unwrap_or(&device.mac);
|
|
let Some(found) = discovered
|
|
.iter()
|
|
.find(|candidate| candidate.mac.eq_ignore_ascii_case(cloud_id))
|
|
else {
|
|
return Err(AppError::BadRequest(format!(
|
|
"cannot restore GREE Cloud device {}: it is not present in the configured account",
|
|
device.name
|
|
)));
|
|
};
|
|
device.key = Some(found.key.clone());
|
|
let normalized_cloud_mac = found.mac.replace([':', '-'], "").to_ascii_uppercase();
|
|
device.cloud_device_id = Some(normalized_cloud_mac.clone());
|
|
device.cloud_parent_mac = Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac));
|
|
device.cloud_account_id = Some(settings.account_id.clone());
|
|
if device.model.trim().is_empty() {
|
|
device.model = found.model.clone().unwrap_or_default();
|
|
}
|
|
if device.firmware.trim().is_empty() {
|
|
device.firmware = found.version.clone().unwrap_or_default();
|
|
}
|
|
}
|
|
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)?;
|
|
hydrate_imported_cloud_device_keys(&state, &mut export).await?;
|
|
|
|
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
|
|
.debug_cloud_requests
|
|
.store(export.settings.debug.cloud_requests, Ordering::Relaxed);
|
|
state
|
|
.debug_cloud_mqtt
|
|
.store(export.settings.debug.cloud_mqtt, 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})))
|
|
}
|