This commit is contained in:
Mateusz Gruszczyński
2026-09-14 16:32:28 +02:00
parent c3fbc5ccc6
commit 021cbddba5
68 changed files with 7780 additions and 202 deletions
+102 -5
View File
@@ -17,12 +17,16 @@ async fn export_configuration(
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.12.x".into()));
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.0".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest(
@@ -108,14 +112,16 @@ fn validate_configuration_devices_and_zones(
export: &ConfigurationExport,
ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> {
let device_macs: std::collections::HashSet<&str> = export
// 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.mac.as_str())
.map(|item| (item.connection_type, item.mac.as_str()))
.collect();
if device_macs.len() != export.devices.len() {
if device_transport_macs.len() != export.devices.len() {
return Err(AppError::BadRequest(
"import contains duplicate device MAC addresses".into(),
"import contains duplicate device MAC addresses for the same connection type".into(),
));
}
if export
@@ -557,6 +563,15 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result
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);
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 =
@@ -601,6 +616,81 @@ fn prepare_configuration_import(export: &mut ConfigurationExport) -> Result<(),
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],
@@ -720,6 +810,7 @@ async fn import_configuration(
) -> 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;
@@ -740,6 +831,12 @@ async fn import_configuration(
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