v0.14.0
This commit is contained in:
+102
-5
@@ -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
|
||||
|
||||
+125
-14
@@ -12,7 +12,9 @@ async fn discover(
|
||||
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
|
||||
let passes = request.passes.unwrap_or(3).clamp(1, 10);
|
||||
let discovered = state
|
||||
.gree
|
||||
.providers
|
||||
.local()
|
||||
.client()
|
||||
.discover(
|
||||
&broadcast,
|
||||
Duration::from_millis(timeout_ms),
|
||||
@@ -38,7 +40,7 @@ async fn discover(
|
||||
// Bind right after discovery. GREE modules can have a short bind window;
|
||||
// bind() also refreshes it with a direct scan before the handshake.
|
||||
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&merged).await {
|
||||
match state.providers.local().client().bind(&merged).await {
|
||||
Ok(bound) => {
|
||||
merged.key = Some(bound.key);
|
||||
merged.protocol_version = bound.protocol_version;
|
||||
@@ -96,6 +98,11 @@ async fn add_device(
|
||||
id: format!("gree-{}", normalized_mac.to_ascii_lowercase()),
|
||||
mac: normalized_mac,
|
||||
name: input.name.trim().to_string(),
|
||||
connection_type: ConnectionType::Local,
|
||||
connection_status: ConnectionStatus::Unknown,
|
||||
cloud_device_id: None,
|
||||
cloud_parent_mac: None,
|
||||
cloud_account_id: None,
|
||||
ip: input.ip,
|
||||
port: input.port,
|
||||
protocol_version: input.protocol_version.min(2),
|
||||
@@ -126,6 +133,11 @@ async fn add_device(
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
supports_buzzer_control: None,
|
||||
supports_energy_meter: None,
|
||||
total_energy_kwh: None,
|
||||
compressor_frequency_hz: None,
|
||||
last_cloud_sync: None,
|
||||
current_temperature: if input.simulated { Some(25.0) } else { None },
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
@@ -134,6 +146,13 @@ async fn add_device(
|
||||
last_seen: if input.simulated { Some(now) } else { None },
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
pending_command: false,
|
||||
capabilities: crate::models::DeviceCapabilities::default(),
|
||||
energy_source: crate::models::EnergySourcePreference::Auto,
|
||||
ha_energy_entity_id: None,
|
||||
ha_energy_unit: None,
|
||||
ha_energy_device_class: None,
|
||||
ha_energy_state_class: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -173,6 +192,13 @@ async fn patch_device(
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type == ConnectionType::GreeCloud
|
||||
&& (patch.ip.is_some() || patch.port.is_some() || patch.protocol_version.is_some() || patch.key.is_some())
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"IP, UDP port, local protocol and local key are not configurable for GREE Cloud devices".into(),
|
||||
));
|
||||
}
|
||||
if let Some(v) = patch.name {
|
||||
if !v.trim().is_empty() {
|
||||
device.name = v.trim().to_string();
|
||||
@@ -206,6 +232,44 @@ async fn patch_device(
|
||||
if let Some(v) = patch.enabled {
|
||||
device.enabled = v;
|
||||
}
|
||||
if let Some(v) = patch.energy_source { device.energy_source = v; }
|
||||
if let Some(v) = patch.ha_energy_entity_id { device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty()); }
|
||||
if let Some(v) = patch.ha_energy_unit { device.ha_energy_unit = v.filter(|x| !x.trim().is_empty()); }
|
||||
if let Some(v) = patch.ha_energy_device_class { device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty()); }
|
||||
if let Some(v) = patch.ha_energy_state_class { device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty()); }
|
||||
device.refresh_capabilities();
|
||||
if device.energy_source == EnergySourcePreference::GreeCloud && !device.capabilities.energy_meter {
|
||||
return Err(AppError::BadRequest(
|
||||
"GREE Cloud energy is not available for this device".into(),
|
||||
));
|
||||
}
|
||||
if device.energy_source == EnergySourcePreference::HomeAssistant
|
||||
&& device.ha_energy_entity_id.as_deref().unwrap_or_default().is_empty()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"select a Home Assistant cumulative energy sensor first".into(),
|
||||
));
|
||||
}
|
||||
if device.ha_energy_entity_id.is_some() {
|
||||
if device.ha_energy_device_class.as_deref() != Some("energy") {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must have device_class=energy".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(device.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must have state_class=total or total_increasing".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
device.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(),
|
||||
Some("wh" | "kwh")
|
||||
) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must use Wh or kWh".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
@@ -227,13 +291,17 @@ async fn delete_device(
|
||||
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;
|
||||
if state.db.get_device(&id)?.is_none() {
|
||||
return Err(AppError::NotFound(format!("device {id}")));
|
||||
}
|
||||
if state.db.list_automations()?.iter().any(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
}) {
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
let automations = state.db.list_automations()?;
|
||||
if device.connection_type == ConnectionType::Local
|
||||
&& automations.iter().any(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
})
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"device is used by an automation; remove or retarget that automation first".into(),
|
||||
));
|
||||
@@ -251,8 +319,40 @@ async fn delete_device(
|
||||
for zone_id in &sorted_zone_ids {
|
||||
zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
||||
if device.connection_type == ConnectionType::Local {
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
||||
} else {
|
||||
// Cloud removal must remain possible even when the physical unit is offline. Remove
|
||||
// controller-only references that would otherwise block deletion, but never send an
|
||||
// OFF/status request and never depend on MQTT. Local keeps the historical safeguards.
|
||||
let groups = state.db.list_groups()?;
|
||||
let emptied_group_ids: std::collections::HashSet<String> = groups
|
||||
.iter()
|
||||
.filter(|group| {
|
||||
!group.zone_ids.is_empty()
|
||||
&& group.zone_ids.iter().all(|zone_id| removed_zone_ids.contains(zone_id))
|
||||
})
|
||||
.map(|group| group.id.clone())
|
||||
.collect();
|
||||
for automation in automations.iter().filter(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
|| item
|
||||
.action_group_id
|
||||
.as_ref()
|
||||
.is_some_and(|group_id| emptied_group_ids.contains(group_id))
|
||||
|| item
|
||||
.action_zone_id
|
||||
.as_ref()
|
||||
.is_some_and(|zone_id| removed_zone_ids.contains(zone_id))
|
||||
}) {
|
||||
if state.db.delete_automation(&automation.id)? {
|
||||
state.broadcast("automation.deleted", json!({"id": automation.id.clone()}));
|
||||
}
|
||||
}
|
||||
state.providers.cloud().unregister_device(&id).await;
|
||||
}
|
||||
if !state.db.delete_device(&id)? {
|
||||
return Err(AppError::NotFound(format!("device {id}")));
|
||||
}
|
||||
@@ -262,9 +362,10 @@ async fn delete_device(
|
||||
"info",
|
||||
"device.deleted",
|
||||
"Device deleted",
|
||||
json!({"device_id": id}),
|
||||
json!({"device_id": id, "connection_type": device.connection_type}),
|
||||
);
|
||||
state.broadcast("device.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -278,11 +379,15 @@ async fn bind_device(
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type == ConnectionType::GreeCloud {
|
||||
return Err(AppError::BadRequest("bind is only available for Local/LAN devices".into()));
|
||||
}
|
||||
if device.simulated {
|
||||
return Ok(Json(device));
|
||||
}
|
||||
let bound = state
|
||||
.gree
|
||||
.providers
|
||||
.local()
|
||||
.bind(&device)
|
||||
.await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
@@ -290,6 +395,7 @@ async fn bind_device(
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
device.online = true;
|
||||
device.connection_status = ConnectionStatus::Online;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
@@ -319,8 +425,13 @@ async fn probe_device(
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type == ConnectionType::GreeCloud {
|
||||
return Err(AppError::BadRequest("UDP probe is only available for Local/LAN devices".into()));
|
||||
}
|
||||
let response_time_ms = state
|
||||
.gree
|
||||
.providers
|
||||
.local()
|
||||
.client()
|
||||
.probe(&device)
|
||||
.await
|
||||
.map_err(|err| AppError::Device(err.to_string()))?;
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
fn cloud_debug_event(state: &AppState, data: Value) {
|
||||
if state.debug_cloud_requests.load(Ordering::Relaxed) {
|
||||
state.broadcast_with_control_plan_invalidation("gree_cloud.request", data, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn cloud_error_kind(error: &anyhow::Error) -> &'static str {
|
||||
let text = format!("{error:#}").to_ascii_lowercase();
|
||||
if text.contains("authentication failed") || text.contains("login failed") {
|
||||
"authentication_error"
|
||||
} else if text.contains("timeout") || text.contains("timed out") {
|
||||
"timeout"
|
||||
} else if text.contains("http 5") || text.contains("service unavailable") {
|
||||
"api_unavailable"
|
||||
} else if text.contains("connect") || text.contains("dns") || text.contains("network") {
|
||||
"network_error"
|
||||
} else {
|
||||
"api_error"
|
||||
}
|
||||
}
|
||||
|
||||
async fn cloud_api_from_settings(state: &AppState) -> Result<crate::protocol::gree_cloud::GreeCloudApi, AppError> {
|
||||
let settings = state.settings.read().await.gree_cloud.clone();
|
||||
crate::protocol::gree_cloud::GreeCloudApi::for_region(
|
||||
state.http.clone(),
|
||||
&settings.region,
|
||||
&settings.username,
|
||||
&settings.password,
|
||||
)
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))
|
||||
}
|
||||
|
||||
async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let mut api = cloud_api_from_settings(&state).await?;
|
||||
let started = Instant::now();
|
||||
cloud_debug_event(&state, json!({"operation":"test_connection","phase":"sent"}));
|
||||
let result = async {
|
||||
api.login().await?;
|
||||
let devices = api.get_all_devices().await?;
|
||||
Ok::<usize, anyhow::Error>(devices.len())
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(device_count) => {
|
||||
let now = Utc::now();
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"test_connection",
|
||||
"phase":"response",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"device_count": device_count,
|
||||
}));
|
||||
{
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.gree_cloud.last_successful_contact = Some(now);
|
||||
settings.gree_cloud.last_rest_response_time_ms =
|
||||
Some(started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.login_success",
|
||||
"GREE Cloud connection test succeeded",
|
||||
json!({"device_count": device_count}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"status": "connected",
|
||||
"device_count": device_count,
|
||||
"last_successful_contact": now,
|
||||
})))
|
||||
}
|
||||
Err(error) => {
|
||||
let kind = cloud_error_kind(&error);
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"test_connection",
|
||||
"phase":"error",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": kind,
|
||||
}));
|
||||
tracing::warn!(kind, "GREE Cloud connection test failed");
|
||||
state.log(
|
||||
"warn",
|
||||
"gree_cloud.login_failure",
|
||||
"GREE Cloud connection test failed",
|
||||
json!({"kind": kind}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"ok": false,
|
||||
"status": kind,
|
||||
"message": match kind {
|
||||
"authentication_error" => "Invalid GREE Cloud login/password or authorization was rejected",
|
||||
"timeout" => "GREE Cloud request timed out",
|
||||
"network_error" => "Cannot reach GREE Cloud",
|
||||
"api_unavailable" => "GREE Cloud API is temporarily unavailable",
|
||||
_ => "GREE Cloud returned an unexpected response",
|
||||
}
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_gree_cloud_devices(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let mut api = cloud_api_from_settings(&state).await?;
|
||||
let started = Instant::now();
|
||||
cloud_debug_event(&state, json!({"operation":"discovery","phase":"sent"}));
|
||||
api.login()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"discovery",
|
||||
"phase":"error",
|
||||
"stage":"login",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}));
|
||||
AppError::Dependency(format!("GREE Cloud login failed: {err}"))
|
||||
})?;
|
||||
let devices = api
|
||||
.get_all_devices()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"discovery",
|
||||
"phase":"error",
|
||||
"stage":"devices",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}));
|
||||
AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
|
||||
})?;
|
||||
let rest_duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64;
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"discovery",
|
||||
"phase":"response",
|
||||
"duration_ms": rest_duration_ms,
|
||||
"device_count": devices.len(),
|
||||
}));
|
||||
{
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.gree_cloud.last_successful_contact = Some(Utc::now());
|
||||
settings.gree_cloud.last_rest_response_time_ms = Some(rest_duration_ms);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
let existing = state.db.list_devices()?;
|
||||
let views = devices
|
||||
.into_iter()
|
||||
.map(|device| {
|
||||
let id = device.mac.replace([':', '-'], "").to_ascii_uppercase();
|
||||
let already_added = existing.iter().any(|saved| {
|
||||
saved.connection_type == ConnectionType::GreeCloud
|
||||
&& saved.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&id))
|
||||
});
|
||||
crate::protocol::gree_cloud::CloudDeviceView {
|
||||
parent_mac: crate::protocol::gree_cloud::parent_mac(&id),
|
||||
id: id.clone(),
|
||||
name: device.name,
|
||||
mac: id,
|
||||
model: device.model,
|
||||
version: device.version,
|
||||
online: device.online,
|
||||
already_added,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.discovery",
|
||||
&format!("GREE Cloud discovery found {} device(s)", views.len()),
|
||||
json!({"count": views.len()}),
|
||||
);
|
||||
Ok(Json(json!({"count": views.len(), "devices": views})))
|
||||
}
|
||||
|
||||
async fn add_gree_cloud_device(
|
||||
State(state): State<AppState>,
|
||||
Path(cloud_id): Path<String>,
|
||||
) -> Result<(StatusCode, Json<Device>), AppError> {
|
||||
let cloud_id = cloud_id.replace([':', '-'], "").to_ascii_uppercase();
|
||||
if cloud_id.is_empty() {
|
||||
return Err(AppError::BadRequest("cloud device id is required".into()));
|
||||
}
|
||||
if state.db.list_devices()?.iter().any(|device| {
|
||||
device.connection_type == ConnectionType::GreeCloud
|
||||
&& device.cloud_device_id.as_deref() == Some(cloud_id.as_str())
|
||||
}) {
|
||||
return Err(AppError::Conflict(
|
||||
"this GREE Cloud device is already added".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Re-discover server-side so the frontend never needs to submit/store the device cipher key.
|
||||
let mut api = cloud_api_from_settings(&state).await?;
|
||||
let started = Instant::now();
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"sent",
|
||||
"cloud_device_id": cloud_id.clone(),
|
||||
}));
|
||||
api.login()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"error",
|
||||
"stage":"login",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}));
|
||||
AppError::Dependency(format!("GREE Cloud login failed: {err}"))
|
||||
})?;
|
||||
let cloud_device = api
|
||||
.get_all_devices()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"error",
|
||||
"stage":"devices",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}));
|
||||
AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
|
||||
})?
|
||||
.into_iter()
|
||||
.find(|device| device.mac.eq_ignore_ascii_case(&cloud_id))
|
||||
.ok_or_else(|| AppError::NotFound(format!("GREE Cloud device {cloud_id}")))?;
|
||||
cloud_debug_event(&state, json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"response",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"cloud_device_id": cloud_id.clone(),
|
||||
}));
|
||||
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let now = Utc::now();
|
||||
let account_id = state.settings.read().await.gree_cloud.account_id.clone();
|
||||
let normalized_cloud_mac = cloud_device.mac.replace([':', '-'], "").to_ascii_uppercase();
|
||||
let device = Device {
|
||||
id: format!("gree-cloud-{}", normalized_cloud_mac.to_ascii_lowercase()),
|
||||
mac: normalized_cloud_mac.clone(),
|
||||
name: if cloud_device.name.trim().is_empty() {
|
||||
format!("GREE Cloud {}", &normalized_cloud_mac)
|
||||
} else {
|
||||
cloud_device.name.clone()
|
||||
},
|
||||
connection_type: ConnectionType::GreeCloud,
|
||||
connection_status: ConnectionStatus::CloudDisconnected,
|
||||
cloud_device_id: Some(normalized_cloud_mac.clone()),
|
||||
cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac(&normalized_cloud_mac)),
|
||||
cloud_account_id: Some(account_id),
|
||||
ip: String::new(),
|
||||
port: 0,
|
||||
// The reference HA integration currently creates CloudDevice with cipher_version=1.
|
||||
protocol_version: 1,
|
||||
model: cloud_device.model.unwrap_or_default(),
|
||||
firmware: cloud_device.version.unwrap_or_default(),
|
||||
key: Some(cloud_device.key),
|
||||
cid: Some("gree-cloud".into()),
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
quiet: false,
|
||||
quiet_wire_value: None,
|
||||
turbo: false,
|
||||
light: false,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: None,
|
||||
supports_quiet: None,
|
||||
supports_turbo: None,
|
||||
supports_air: None,
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
supports_buzzer_control: None,
|
||||
supports_energy_meter: None,
|
||||
total_energy_kwh: None,
|
||||
compressor_frequency_hz: None,
|
||||
last_cloud_sync: None,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: false,
|
||||
response_time_ms: None,
|
||||
last_seen: None,
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
pending_command: false,
|
||||
capabilities: crate::models::DeviceCapabilities {
|
||||
vertical_swing: false,
|
||||
horizontal_swing: false,
|
||||
..crate::models::DeviceCapabilities::default()
|
||||
},
|
||||
energy_source: crate::models::EnergySourcePreference::Auto,
|
||||
ha_energy_entity_id: None,
|
||||
ha_energy_unit: None,
|
||||
ha_energy_device_class: None,
|
||||
ha_energy_state_class: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.db.save_device(&device)?;
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.device_added",
|
||||
&format!("Added GREE Cloud device {}", device.name),
|
||||
json!({"device_id": device.id, "cloud_device_id": device.cloud_device_id}),
|
||||
);
|
||||
state.broadcast("device.created", serde_json::to_value(&device)?);
|
||||
Ok((StatusCode::CREATED, Json(device)))
|
||||
}
|
||||
|
||||
async fn gree_cloud_status(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.gree_cloud.clone();
|
||||
let mqtt_connected = state.providers.cloud().is_connected().await;
|
||||
let cloud_devices = state
|
||||
.db
|
||||
.list_devices()?
|
||||
.into_iter()
|
||||
.filter(|device| device.connection_type == ConnectionType::GreeCloud)
|
||||
.collect::<Vec<_>>();
|
||||
let account_status = if !settings.enabled {
|
||||
"disabled"
|
||||
} else if settings.username.trim().is_empty() || settings.password.trim().is_empty() {
|
||||
"not_configured"
|
||||
} else if cloud_devices
|
||||
.iter()
|
||||
.any(|device| device.connection_status == ConnectionStatus::AuthenticationError)
|
||||
{
|
||||
"authentication_error"
|
||||
} else if mqtt_connected {
|
||||
"connected"
|
||||
} else {
|
||||
"cloud_disconnected"
|
||||
};
|
||||
let runtime = state.providers.cloud().runtime_status().await;
|
||||
Ok(Json(json!({
|
||||
"enabled": settings.enabled,
|
||||
"account_status": account_status,
|
||||
"mqtt_status": if mqtt_connected { "connected" } else { "disconnected" },
|
||||
"last_successful_contact": settings.last_successful_contact,
|
||||
"last_rest_response_time_ms": settings.last_rest_response_time_ms,
|
||||
"device_count": cloud_devices.len(),
|
||||
"online_device_count": cloud_devices.iter().filter(|device| device.connection_status == ConnectionStatus::Online).count(),
|
||||
"runtime": runtime,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn reconnect_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.gree_cloud.clone();
|
||||
if !settings.enabled {
|
||||
return Err(AppError::BadRequest("GREE Cloud is disabled".into()));
|
||||
}
|
||||
let devices = state.db.list_devices()?;
|
||||
state.providers.cloud().shutdown().await;
|
||||
state
|
||||
.providers
|
||||
.cloud()
|
||||
.ensure_connected(&settings, &devices)
|
||||
.await
|
||||
.map_err(|error| AppError::Dependency(cloud_public_error_text(&error.to_string())))?;
|
||||
let now = Utc::now();
|
||||
{
|
||||
let mut runtime = state.settings.write().await;
|
||||
runtime.gree_cloud.last_successful_contact = Some(now);
|
||||
state.db.save_runtime_settings(&runtime)?;
|
||||
}
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.reconnect",
|
||||
"GREE Cloud MQTT reconnected",
|
||||
json!({"device_count": devices.iter().filter(|device| device.connection_type == ConnectionType::GreeCloud).count()}),
|
||||
);
|
||||
Ok(Json(json!({"ok": true, "mqtt_status": "connected", "last_successful_contact": now})))
|
||||
}
|
||||
|
||||
async fn cloud_device_diagnostics(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type != ConnectionType::GreeCloud {
|
||||
return Err(AppError::BadRequest("Cloud diagnostics are available only for GREE Cloud devices".into()));
|
||||
}
|
||||
let diagnostics = state.providers.cloud().diagnostics(&device.id).await;
|
||||
Ok(Json(json!({
|
||||
"device_id": device.id,
|
||||
"cloud_device_id": device.cloud_device_id,
|
||||
"connection_status": device.connection_status,
|
||||
"last_sync": device.last_cloud_sync,
|
||||
"capabilities": device.capabilities,
|
||||
"provider": diagnostics,
|
||||
})))
|
||||
}
|
||||
|
||||
fn cloud_public_error_text(error: &str) -> String {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
|
||||
"GREE Cloud authentication failed".into()
|
||||
} else {
|
||||
error.chars().take(300).collect()
|
||||
}
|
||||
}
|
||||
@@ -488,3 +488,214 @@ async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppE
|
||||
let snapshot = engine::get_control_plan_snapshot(&state).await?;
|
||||
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EnergyHistoryQuery {
|
||||
device_id: String,
|
||||
interval: Option<String>,
|
||||
source: Option<String>,
|
||||
days: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
async fn energy_history(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<EnergyHistoryQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
use chrono::{Datelike, NaiveDate, Timelike, Weekday};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&query.device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {}", query.device_id)))?;
|
||||
let interval_name = query.interval.as_deref().unwrap_or("daily");
|
||||
if !matches!(interval_name, "hourly" | "daily" | "weekly" | "monthly") {
|
||||
return Err(AppError::BadRequest(
|
||||
"energy interval must be hourly, daily, weekly or monthly".into(),
|
||||
));
|
||||
}
|
||||
let days = query.days.unwrap_or(31).clamp(1, 3650);
|
||||
let now = Utc::now();
|
||||
let since = now - ChronoDuration::days(days);
|
||||
let month_start_for_load = chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
|
||||
.expect("valid current month")
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("valid midnight"),
|
||||
Utc,
|
||||
);
|
||||
let previous_month_date_for_load = month_start_for_load.date_naive() - ChronoDuration::days(1);
|
||||
let previous_month_start_for_load = chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
chrono::NaiveDate::from_ymd_opt(
|
||||
previous_month_date_for_load.year(),
|
||||
previous_month_date_for_load.month(),
|
||||
1,
|
||||
)
|
||||
.expect("valid previous month")
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("valid midnight"),
|
||||
Utc,
|
||||
);
|
||||
let load_since = since.min(previous_month_start_for_load);
|
||||
let limit = query.limit.unwrap_or(100_000).clamp(1, 200_000);
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = now - ChronoDuration::days(influx.history_threshold_days as i64);
|
||||
let mut storage = "sqlite".to_string();
|
||||
let mut storage_warning: Option<String> = None;
|
||||
let mut samples = if influx.enabled && load_since < cutoff {
|
||||
match influxdb::query_energy(
|
||||
&state.http,
|
||||
&influx,
|
||||
&device.id,
|
||||
None,
|
||||
load_since,
|
||||
cutoff,
|
||||
3600,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut archived) => {
|
||||
archived.extend(state.db.list_energy_readings(&device.id, cutoff, limit)?);
|
||||
storage = "influx+sqlite".into();
|
||||
archived
|
||||
}
|
||||
Err(err) => {
|
||||
storage = "sqlite_fallback".into();
|
||||
storage_warning = Some(err.to_string());
|
||||
state.log(
|
||||
"warn",
|
||||
"influx.query_error",
|
||||
"InfluxDB energy history query failed",
|
||||
json!({"device_id": device.id, "error": err.to_string()}),
|
||||
);
|
||||
state.db.list_energy_readings(&device.id, load_since, limit)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.db.list_energy_readings(&device.id, load_since, limit)?
|
||||
};
|
||||
samples.sort_by_key(|row| row.timestamp);
|
||||
|
||||
let requested_source = query.source.as_deref().unwrap_or("auto");
|
||||
let selected_source = match requested_source {
|
||||
"gree_cloud" => Some("gree_cloud"),
|
||||
"home_assistant" => Some("home_assistant"),
|
||||
"auto" => match device.energy_source {
|
||||
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
|
||||
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
|
||||
EnergySourcePreference::Auto => {
|
||||
if samples.iter().any(|row| row.source == "gree_cloud") {
|
||||
Some("gree_cloud")
|
||||
} else if samples.iter().any(|row| row.source == "home_assistant") {
|
||||
Some("home_assistant")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"energy source must be auto, gree_cloud or home_assistant".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
if let Some(source) = selected_source {
|
||||
samples.retain(|row| row.source == source);
|
||||
} else {
|
||||
samples.clear();
|
||||
}
|
||||
|
||||
fn midnight(date: NaiveDate) -> chrono::DateTime<Utc> {
|
||||
chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
date.and_hms_opt(0, 0, 0).expect("valid midnight"),
|
||||
Utc,
|
||||
)
|
||||
}
|
||||
fn bucket_start(
|
||||
timestamp: chrono::DateTime<Utc>,
|
||||
interval_name: &str,
|
||||
) -> chrono::DateTime<Utc> {
|
||||
let date = timestamp.date_naive();
|
||||
match interval_name {
|
||||
"hourly" => chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
date.and_hms_opt(timestamp.hour(), 0, 0)
|
||||
.expect("valid hour"),
|
||||
Utc,
|
||||
),
|
||||
"weekly" => {
|
||||
let iso = date.iso_week();
|
||||
midnight(
|
||||
NaiveDate::from_isoywd_opt(iso.year(), iso.week(), Weekday::Mon)
|
||||
.expect("valid ISO week"),
|
||||
)
|
||||
}
|
||||
"monthly" => midnight(
|
||||
NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
|
||||
.expect("valid month"),
|
||||
),
|
||||
_ => midnight(date),
|
||||
}
|
||||
}
|
||||
|
||||
let period_samples = samples
|
||||
.iter()
|
||||
.filter(|row| row.timestamp >= since)
|
||||
.collect::<Vec<_>>();
|
||||
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
|
||||
for sample in &period_samples {
|
||||
*buckets
|
||||
.entry(bucket_start(sample.timestamp, interval_name))
|
||||
.or_default() += sample.consumption_kwh.max(0.0);
|
||||
}
|
||||
let buckets = buckets
|
||||
.into_iter()
|
||||
.map(|(start, consumption_kwh)| {
|
||||
json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let today_start = midnight(now.date_naive());
|
||||
let yesterday_start = today_start - ChronoDuration::days(1);
|
||||
let month_start = midnight(
|
||||
NaiveDate::from_ymd_opt(now.year(), now.month(), 1).expect("valid current month"),
|
||||
);
|
||||
let previous_month_date = month_start.date_naive() - ChronoDuration::days(1);
|
||||
let previous_month_start = midnight(
|
||||
NaiveDate::from_ymd_opt(previous_month_date.year(), previous_month_date.month(), 1)
|
||||
.expect("valid previous month"),
|
||||
);
|
||||
let sum_range = |start: chrono::DateTime<Utc>, stop: chrono::DateTime<Utc>| -> f64 {
|
||||
samples
|
||||
.iter()
|
||||
.filter(|row| row.timestamp >= start && row.timestamp < stop)
|
||||
.map(|row| row.consumption_kwh.max(0.0))
|
||||
.sum()
|
||||
};
|
||||
let period_total: f64 = period_samples
|
||||
.iter()
|
||||
.map(|row| row.consumption_kwh.max(0.0))
|
||||
.sum();
|
||||
let latest = samples.last().cloned();
|
||||
|
||||
Ok(Json(json!({
|
||||
"device_id": device.id,
|
||||
"source": selected_source.unwrap_or("none"),
|
||||
"configured_source": device.energy_source,
|
||||
"interval": interval_name,
|
||||
"unit": "kWh",
|
||||
"period_days": days,
|
||||
"storage": storage,
|
||||
"storage_warning": storage_warning,
|
||||
"buckets": buckets,
|
||||
"summary": {
|
||||
"today": sum_range(today_start, now + ChronoDuration::seconds(1)),
|
||||
"yesterday": sum_range(yesterday_start, today_start),
|
||||
"current_month": sum_range(month_start, now + ChronoDuration::seconds(1)),
|
||||
"previous_month": sum_range(previous_month_start, month_start),
|
||||
"period_total": period_total,
|
||||
},
|
||||
"latest": latest,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -95,3 +95,36 @@ async fn test_notifications(
|
||||
.map_err(AppError::Device)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn list_home_assistant_energy_sensors(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.home_assistant.clone();
|
||||
let entities = home_assistant::list_entities(&state.http, &settings)
|
||||
.await
|
||||
.map_err(|error| AppError::Device(error.to_string()))?;
|
||||
let sensors = entities
|
||||
.into_iter()
|
||||
.filter_map(|entity| {
|
||||
let attributes = entity.get("attributes")?.as_object()?;
|
||||
let device_class = attributes.get("device_class")?.as_str()?;
|
||||
let state_class = attributes.get("state_class")?.as_str()?;
|
||||
let unit = attributes.get("unit_of_measurement")?.as_str()?;
|
||||
if device_class != "energy"
|
||||
|| !matches!(state_class, "total" | "total_increasing")
|
||||
|| !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"entity_id": entity.get("entity_id").and_then(Value::as_str).unwrap_or_default(),
|
||||
"name": attributes.get("friendly_name").and_then(Value::as_str).unwrap_or_default(),
|
||||
"state": entity.get("state").and_then(Value::as_str).unwrap_or_default(),
|
||||
"unit": unit,
|
||||
"device_class": device_class,
|
||||
"state_class": state_class,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({"sensors": sensors})))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,20 @@ fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
|
||||
}
|
||||
}
|
||||
|
||||
fn gree_cloud_settings(settings: &RuntimeSettings) -> GreeCloudSettingsView {
|
||||
GreeCloudSettingsView {
|
||||
enabled: settings.gree_cloud.enabled,
|
||||
region: settings.gree_cloud.region.clone(),
|
||||
username: settings.gree_cloud.username.clone(),
|
||||
password_configured: !settings.gree_cloud.password.is_empty(),
|
||||
polling_interval_seconds: settings.gree_cloud.polling_interval_seconds,
|
||||
installation_id: settings.gree_cloud.installation_id.clone(),
|
||||
account_id: settings.gree_cloud.account_id.clone(),
|
||||
last_successful_contact: settings.gree_cloud.last_successful_contact,
|
||||
last_rest_response_time_ms: settings.gree_cloud.last_rest_response_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn history_settings(settings: &RuntimeSettings) -> HistorySettings {
|
||||
HistorySettings {
|
||||
retention_days: settings.history_retention_days,
|
||||
@@ -74,6 +88,7 @@ fn settings_snapshot(settings: &RuntimeSettings) -> SettingsSnapshot {
|
||||
SettingsSnapshot {
|
||||
application: application_settings(settings),
|
||||
gree: gree_settings(settings),
|
||||
gree_cloud: gree_cloud_settings(settings),
|
||||
history: history_settings(settings),
|
||||
influxdb: influxdb_settings(settings),
|
||||
notifications: notification_settings(settings),
|
||||
@@ -209,6 +224,84 @@ async fn update_gree_settings(
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
|
||||
async fn get_gree_cloud_settings(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<GreeCloudSettingsView> {
|
||||
Json(gree_cloud_settings(&*state.settings.read().await))
|
||||
}
|
||||
|
||||
fn apply_gree_cloud_update(
|
||||
current: &GreeCloudSettings,
|
||||
input: GreeCloudSettingsUpdate,
|
||||
) -> Result<GreeCloudSettings, AppError> {
|
||||
if crate::protocol::gree_cloud::region_base_url(input.region.trim()).is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"unsupported GREE Cloud region: {}",
|
||||
input.region
|
||||
)));
|
||||
}
|
||||
let mut next = current.clone();
|
||||
next.enabled = input.enabled;
|
||||
next.region = input.region.trim().to_string();
|
||||
next.username = input.username.trim().to_string();
|
||||
next.polling_interval_seconds = input.polling_interval_seconds.clamp(30, 3600);
|
||||
if let Some(password) = input.password {
|
||||
next.password = password;
|
||||
}
|
||||
if next.enabled && next.username.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"GREE Cloud login/email is required when cloud is enabled".into(),
|
||||
));
|
||||
}
|
||||
if next.enabled && next.password.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"GREE Cloud password is required when cloud is enabled".into(),
|
||||
));
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
async fn update_gree_cloud_settings(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<GreeCloudSettingsUpdate>,
|
||||
) -> Result<Json<GreeCloudSettingsView>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let (payload, reconnect_required) = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let previous = settings.gree_cloud.clone();
|
||||
let next = apply_gree_cloud_update(&previous, input)?;
|
||||
let reconnect_required = previous.enabled != next.enabled
|
||||
|| previous.region != next.region
|
||||
|| previous.username != next.username
|
||||
|| previous.password != next.password;
|
||||
settings.gree_cloud = next;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
(gree_cloud_settings(&settings), reconnect_required)
|
||||
};
|
||||
if reconnect_required {
|
||||
// Never keep a broker session authenticated with stale/disabled account settings.
|
||||
// The reconnect loop will establish a fresh session when Cloud remains enabled.
|
||||
state.providers.cloud().shutdown().await;
|
||||
}
|
||||
state.log(
|
||||
"info",
|
||||
"settings.gree_cloud.updated",
|
||||
"GREE Cloud settings updated",
|
||||
json!({
|
||||
"enabled": payload.enabled,
|
||||
"region": payload.region,
|
||||
"polling_interval_seconds": payload.polling_interval_seconds,
|
||||
"password_configured": payload.password_configured
|
||||
}),
|
||||
);
|
||||
state.broadcast(
|
||||
"settings.gree_cloud.updated",
|
||||
serde_json::to_value(&payload)?,
|
||||
);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn get_history_settings(State(state): State<AppState>) -> Json<HistorySettings> {
|
||||
Json(history_settings(&*state.settings.read().await))
|
||||
}
|
||||
@@ -645,6 +738,12 @@ async fn update_debug_settings(
|
||||
state
|
||||
.debug_gree_frames
|
||||
.store(input.gree_frames, Ordering::Relaxed);
|
||||
state
|
||||
.debug_cloud_requests
|
||||
.store(input.cloud_requests, Ordering::Relaxed);
|
||||
state
|
||||
.debug_cloud_mqtt
|
||||
.store(input.cloud_mqtt, Ordering::Relaxed);
|
||||
state.broadcast("settings.debug.updated", serde_json::to_value(&input)?);
|
||||
Ok(Json(input))
|
||||
}
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
|
||||
}
|
||||
|
||||
fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse {
|
||||
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
|
||||
let (received_frames_total, received_frames_by_device) = state.providers.local().client().received_frame_stats();
|
||||
SystemInfoResponse {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
uptime_seconds: state.started.elapsed().as_secs(),
|
||||
|
||||
Reference in New Issue
Block a user