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
+125 -14
View File
@@ -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()))?;
+418
View File
@@ -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()
}
}
+211
View File
@@ -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,
})))
}
+33
View File
@@ -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})))
}
+99
View File
@@ -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
View File
@@ -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(),