This commit is contained in:
Mateusz Gruszczyński
2026-09-17 08:52:02 +02:00
parent ff4f2b60e7
commit b7846c3b9f
87 changed files with 3783 additions and 1418 deletions
+12 -5
View File
@@ -37,6 +37,10 @@ async fn custom_chart_page(State(state): State<AppState>, headers: HeaderMap) ->
"__GREE_THEME_INIT_ASSET__",
&format!("{base}{THEME_INIT_ASSET_PATH}"),
)
.replace(
"__GREE_LANG_INIT_ASSET__",
&format!("{base}{LANG_INIT_ASSET_PATH}"),
)
.replace(
"__GREE_STYLES_ASSET__",
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
@@ -50,10 +54,9 @@ async fn custom_chart_page(State(state): State<AppState>, headers: HeaderMap) ->
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("public, no-cache"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
@@ -183,7 +186,11 @@ async fn language_file(Path(file): Path<String>) -> Response {
.iter()
.find(|(language, _)| *language == code)
{
return static_response(*body, "application/json; charset=utf-8", "public, max-age=31536000, immutable");
return static_response(
*body,
"application/json; charset=utf-8",
"public, max-age=31536000, immutable",
);
}
let mut response = Response::new(Body::from("Language not found"));
*response.status_mut() = StatusCode::NOT_FOUND;
+68 -20
View File
@@ -50,7 +50,11 @@ fn collect_configuration_ids(
let ids = ConfigurationIds {
devices: export.devices.iter().map(|item| item.id.as_str()).collect(),
zones: export.zones.iter().map(|item| item.id.as_str()).collect(),
device_groups: export.device_groups.iter().map(|item| item.id.as_str()).collect(),
device_groups: export
.device_groups
.iter()
.map(|item| item.id.as_str())
.collect(),
schedules: export
.schedules
.iter()
@@ -190,21 +194,33 @@ fn validate_configuration_devices_and_zones(
}
if !installation_devices.insert(device_id.as_str()) {
return Err(AppError::BadRequest(
"import assigns one device to more than one split/multisplit installation".into(),
"import assigns one device to more than one split/multisplit installation"
.into(),
));
}
}
if group.energy_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
if group
.energy_device_id
.as_deref()
.is_some_and(|id| !group.device_ids.iter().any(|member| member == id))
{
return Err(AppError::BadRequest(
"import contains an installation energy source outside the installation".into(),
));
}
if group.energy_source == EnergySourcePreference::GreeCloud && group.energy_device_id.is_none() {
if group.energy_source == EnergySourcePreference::GreeCloud
&& group.energy_device_id.is_none()
{
return Err(AppError::BadRequest(
"import contains a GREE Cloud installation without an energy source device".into(),
));
}
if group.energy_source == EnergySourcePreference::HomeAssistant && group.ha_energy_entity_id.as_deref().map_or(true, |value| value.trim().is_empty()) {
if group.energy_source == EnergySourcePreference::HomeAssistant
&& group
.ha_energy_entity_id
.as_deref()
.map_or(true, |value| value.trim().is_empty())
{
return Err(AppError::BadRequest(
"import contains a Home Assistant installation without an energy entity".into(),
));
@@ -213,23 +229,43 @@ fn validate_configuration_devices_and_zones(
let energy_device = export.devices.iter().find(|device| device.id == energy_device_id).ok_or_else(|| {
AppError::BadRequest("import contains an installation energy source referencing a missing device".into())
})?;
if energy_device.connection_type != ConnectionType::GreeCloud || !energy_device.capabilities.energy_meter {
if energy_device.connection_type != ConnectionType::GreeCloud
|| !energy_device.capabilities.energy_meter
{
return Err(AppError::BadRequest(
"import contains an installation energy source without a GREE Cloud energy meter".into(),
));
}
}
if group.ha_energy_entity_id.as_deref().is_some_and(|value| !value.trim().is_empty()) {
if group
.ha_energy_entity_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
if group.ha_energy_device_class.as_deref() != Some("energy")
|| !matches!(group.ha_energy_state_class.as_deref(), Some("total" | "total_increasing"))
|| !matches!(group.ha_energy_unit.as_deref().map(str::to_ascii_lowercase).as_deref(), Some("wh" | "kwh"))
|| !matches!(
group.ha_energy_state_class.as_deref(),
Some("total" | "total_increasing")
)
|| !matches!(
group
.ha_energy_unit
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("wh" | "kwh")
)
{
return Err(AppError::BadRequest(
"import contains an invalid Home Assistant cumulative energy entity".into(),
));
}
}
if group.outdoor_temperature_device_id.as_deref().is_some_and(|id| !group.device_ids.iter().any(|member| member == id)) {
if group
.outdoor_temperature_device_id
.as_deref()
.is_some_and(|id| !group.device_ids.iter().any(|member| member == id))
{
return Err(AppError::BadRequest(
"import contains an installation outdoor-temperature source outside the installation".into(),
));
@@ -652,10 +688,14 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result
}
settings.gree_cloud.polling_interval_seconds =
settings.gree_cloud.polling_interval_seconds.clamp(30, 3600);
settings.gree_cloud.connectivity_metrics_interval_seconds =
settings.gree_cloud.connectivity_metrics_interval_seconds.clamp(30, 3600);
settings.gree_cloud.connectivity_metrics_sample_count =
settings.gree_cloud.connectivity_metrics_sample_count.clamp(1, 10);
settings.gree_cloud.connectivity_metrics_interval_seconds = settings
.gree_cloud
.connectivity_metrics_interval_seconds
.clamp(30, 3600);
settings.gree_cloud.connectivity_metrics_sample_count = settings
.gree_cloud
.connectivity_metrics_sample_count
.clamp(1, 10);
if settings.gree_cloud.account_id.trim().is_empty() {
settings.gree_cloud.account_id = "default".into();
}
@@ -710,8 +750,12 @@ async fn hydrate_imported_cloud_device_keys(
) -> 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)
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;
@@ -732,9 +776,11 @@ async fn hydrate_imported_cloud_device_keys(
&settings.username,
&settings.password,
)
.map_err(|err| AppError::BadRequest(format!(
"cannot restore GREE Cloud device secrets from account: {err}"
)))?;
.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}"
@@ -767,7 +813,9 @@ async fn hydrate_imported_cloud_device_keys(
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_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();
+107 -28
View File
@@ -22,7 +22,9 @@ struct DeviceGroupInput {
}
fn normalize_optional(value: Option<String>) -> Option<String> {
value.map(|item| item.trim().to_string()).filter(|item| !item.is_empty())
value
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
}
fn validate_device_group_input(
@@ -33,63 +35,118 @@ fn validate_device_group_input(
if input.name.trim().is_empty() {
return Err(AppError::BadRequest("installation name is required".into()));
}
let mut device_ids = input.device_ids.iter().map(|id| id.trim().to_string()).filter(|id| !id.is_empty()).collect::<Vec<_>>();
let mut device_ids = input
.device_ids
.iter()
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.collect::<Vec<_>>();
device_ids.sort();
device_ids.dedup();
if device_ids.is_empty() {
return Err(AppError::BadRequest("installation must contain at least one device".into()));
return Err(AppError::BadRequest(
"installation must contain at least one device".into(),
));
}
if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 {
return Err(AppError::BadRequest("split installation must contain exactly one device".into()));
return Err(AppError::BadRequest(
"split installation must contain exactly one device".into(),
));
}
for device_id in &device_ids {
if state.db.get_device(device_id)?.is_none() {
return Err(AppError::BadRequest(format!("installation references missing device {device_id}")));
return Err(AppError::BadRequest(format!(
"installation references missing device {device_id}"
)));
}
}
for existing in state.db.list_device_groups()? {
if editing_id == Some(existing.id.as_str()) { continue; }
if let Some(device_id) = device_ids.iter().find(|id| existing.device_ids.iter().any(|other| other == *id)) {
return Err(AppError::BadRequest(format!("device {device_id} already belongs to installation '{}'", existing.name)));
if editing_id == Some(existing.id.as_str()) {
continue;
}
if let Some(device_id) = device_ids
.iter()
.find(|id| existing.device_ids.iter().any(|other| other == *id))
{
return Err(AppError::BadRequest(format!(
"device {device_id} already belongs to installation '{}'",
existing.name
)));
}
}
let energy_device_id = normalize_optional(input.energy_device_id.clone());
if let Some(ref id) = energy_device_id {
if !device_ids.iter().any(|device_id| device_id == id) {
return Err(AppError::BadRequest("energy source device must belong to this installation".into()));
return Err(AppError::BadRequest(
"energy source device must belong to this installation".into(),
));
}
let device = state.db.get_device(id)?.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?;
if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter {
return Err(AppError::BadRequest("selected device does not expose GREE Cloud energy".into()));
let device = state
.db
.get_device(id)?
.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?;
if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter
{
return Err(AppError::BadRequest(
"selected device does not expose GREE Cloud energy".into(),
));
}
}
if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() {
return Err(AppError::BadRequest("select a GREE Cloud energy source device".into()));
return Err(AppError::BadRequest(
"select a GREE Cloud energy source device".into(),
));
}
let entity = normalize_optional(input.ha_energy_entity_id.clone());
if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() {
return Err(AppError::BadRequest("select a Home Assistant cumulative energy sensor".into()));
return Err(AppError::BadRequest(
"select a Home Assistant cumulative energy sensor".into(),
));
}
if entity.is_some() {
if input.ha_energy_device_class.as_deref() != Some("energy") {
return Err(AppError::BadRequest("Home Assistant energy sensor must have device_class=energy".into()));
return Err(AppError::BadRequest(
"Home Assistant energy sensor must have device_class=energy".into(),
));
}
if !matches!(input.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!(
input.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!(input.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()));
if !matches!(
input
.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(),
));
}
}
if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) {
if !device_ids.iter().any(|device_id| device_id == &id) {
return Err(AppError::BadRequest("outdoor temperature source must belong to this installation".into()));
return Err(AppError::BadRequest(
"outdoor temperature source must belong to this installation".into(),
));
}
}
Ok(device_ids)
}
fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: DeviceGroupInput, device_ids: Vec<String>) -> DeviceGroup {
fn device_group_from_input(
id: String,
existing: Option<DeviceGroup>,
input: DeviceGroupInput,
device_ids: Vec<String>,
) -> DeviceGroup {
let now = Utc::now();
DeviceGroup {
id,
@@ -108,15 +165,27 @@ fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: Dev
}
}
async fn list_device_groups(State(state): State<AppState>) -> Result<Json<Vec<DeviceGroup>>, AppError> {
async fn list_device_groups(
State(state): State<AppState>,
) -> Result<Json<Vec<DeviceGroup>>, AppError> {
Ok(Json(state.db.list_device_groups()?))
}
async fn get_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<DeviceGroup>, AppError> {
state.db.get_device_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device group {id}")))
async fn get_device_group(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<DeviceGroup>, AppError> {
state
.db
.get_device_group(&id)?
.map(Json)
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))
}
async fn create_device_group(State(state): State<AppState>, Json(input): Json<DeviceGroupInput>) -> Result<(StatusCode, Json<DeviceGroup>), AppError> {
async fn create_device_group(
State(state): State<AppState>,
Json(input): Json<DeviceGroupInput>,
) -> Result<(StatusCode, Json<DeviceGroup>), AppError> {
let _guard = state.lock_configuration_operation().await;
let device_ids = validate_device_group_input(&state, &input, None)?;
let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids);
@@ -125,9 +194,16 @@ async fn create_device_group(State(state): State<AppState>, Json(input): Json<De
Ok((StatusCode::CREATED, Json(group)))
}
async fn update_device_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<DeviceGroupInput>) -> Result<Json<DeviceGroup>, AppError> {
async fn update_device_group(
State(state): State<AppState>,
Path(id): Path<String>,
Json(input): Json<DeviceGroupInput>,
) -> Result<Json<DeviceGroup>, AppError> {
let _guard = state.lock_configuration_operation().await;
let existing = state.db.get_device_group(&id)?.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let existing = state
.db
.get_device_group(&id)?
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let device_ids = validate_device_group_input(&state, &input, Some(&id))?;
let group = device_group_from_input(id, Some(existing), input, device_ids);
state.db.save_device_group(&group)?;
@@ -135,7 +211,10 @@ async fn update_device_group(State(state): State<AppState>, Path(id): Path<Strin
Ok(Json(group))
}
async fn delete_device_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
async fn delete_device_group(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let _guard = state.lock_configuration_operation().await;
if !state.db.delete_device_group(&id)? {
return Err(AppError::NotFound(format!("device group {id}")));
+48 -14
View File
@@ -193,7 +193,10 @@ async fn patch_device(
.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())
&& (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(),
@@ -232,19 +235,35 @@ 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()); }
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 {
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()
&& 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(),
@@ -256,13 +275,21 @@ async fn patch_device(
"Home Assistant energy sensor must have device_class=energy".into(),
));
}
if !matches!(device.ha_energy_state_class.as_deref(), Some("total" | "total_increasing")) {
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(),
"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(),
device
.ha_energy_unit
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("wh" | "kwh")
) {
return Err(AppError::BadRequest(
@@ -331,7 +358,10 @@ async fn delete_device(
.iter()
.filter(|group| {
!group.zone_ids.is_empty()
&& group.zone_ids.iter().all(|zone_id| removed_zone_ids.contains(zone_id))
&& group
.zone_ids
.iter()
.all(|zone_id| removed_zone_ids.contains(zone_id))
})
.map(|group| group.id.clone())
.collect();
@@ -380,7 +410,9 @@ async fn bind_device(
.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()));
return Err(AppError::BadRequest(
"bind is only available for Local/LAN devices".into(),
));
}
if device.simulated {
return Ok(Json(device));
@@ -426,7 +458,9 @@ async fn probe_device(
.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()));
return Err(AppError::BadRequest(
"UDP probe is only available for Local/LAN devices".into(),
));
}
let response_time_ms = state
.providers
+102 -65
View File
@@ -19,7 +19,9 @@ fn cloud_error_kind(error: &anyhow::Error) -> &'static str {
}
}
async fn cloud_api_from_settings(state: &AppState) -> Result<crate::protocol::gree_cloud::GreeCloudApi, AppError> {
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(),
@@ -33,7 +35,10 @@ async fn cloud_api_from_settings(state: &AppState) -> Result<crate::protocol::gr
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"}));
cloud_debug_event(
&state,
json!({"operation":"test_connection","phase":"sent"}),
);
let result = async {
api.login().await?;
let devices = api.get_all_devices().await?;
@@ -44,12 +49,15 @@ async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, A
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,
}));
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;
@@ -73,12 +81,15 @@ async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, A
}
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,
}));
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",
@@ -107,38 +118,42 @@ async fn discover_gree_cloud_devices(
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!({
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!({
}),
);
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}"))
})?;
}),
);
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(),
}));
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;
@@ -153,7 +168,10 @@ async fn discover_gree_cloud_devices(
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))
&& 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),
@@ -196,50 +214,63 @@ async fn add_gree_cloud_device(
// 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!({
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}"))
})?;
}),
);
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),
}));
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(),
}));
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 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(),
@@ -251,7 +282,9 @@ async fn add_gree_cloud_device(
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_parent_mac: Some(crate::protocol::gree_cloud::parent_mac(
&normalized_cloud_mac,
)),
cloud_account_id: Some(account_id),
ip: String::new(),
port: 0,
@@ -383,7 +416,9 @@ async fn reconnect_gree_cloud(State(state): State<AppState>) -> Result<Json<Valu
"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})))
Ok(Json(
json!({"ok": true, "mqtt_status": "connected", "last_successful_contact": now}),
))
}
async fn cloud_device_diagnostics(
@@ -395,7 +430,9 @@ async fn cloud_device_diagnostics(
.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()));
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!({
+244 -152
View File
@@ -133,11 +133,7 @@ fn sensor_history_with_fallback(
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
continue;
}
let entity_id = zone
.ha_entity_id
.as_deref()
.map(str::trim)
.unwrap_or("");
let entity_id = zone.ha_entity_id.as_deref().map(str::trim).unwrap_or("");
if entity_id.is_empty() {
continue;
}
@@ -177,9 +173,10 @@ fn sensor_history_with_fallback(
if entity_id.is_empty() || existing.contains(entity_id) {
continue;
}
let rows = state
.db
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
let rows =
state
.db
.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
let mut added = false;
for row in rows {
if let Some(temperature) = row.outdoor_temperature {
@@ -342,13 +339,7 @@ async fn combined_sensor_history(
.db
.list_ha_history(entity_id, start, bucket_seconds, limit)?)
} else {
sensor_history_with_fallback(
state,
start,
bucket_seconds,
limit,
outdoor_entity,
)
sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity)
}
};
if !influx.enabled || since >= cutoff {
@@ -526,6 +517,7 @@ struct PublicChartPoint {
struct PublicChartSeries {
key: String,
label: String,
label_key: &'static str,
dashed: bool,
points: Vec<PublicChartPoint>,
}
@@ -537,7 +529,9 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
.iter()
.any(|item| item.is_empty() || item.len() > 256)
{
return Err(AppError::BadRequest("invalid custom chart definition".into()));
return Err(AppError::BadRequest(
"invalid custom chart definition".into(),
));
}
for key in series {
@@ -545,7 +539,7 @@ fn validate_public_chart_spec(series: &[String]) -> Result<(), AppError> {
let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default();
if id.trim().is_empty() || public_chart_field_label(kind, field, "en").is_none() {
if id.trim().is_empty() || public_chart_field_label_key(kind, field).is_none() {
return Err(AppError::BadRequest(format!(
"unsupported custom chart series: {key}"
)));
@@ -568,22 +562,17 @@ async fn create_public_custom_chart(
validate_public_chart_spec(&input.series)?;
let hours = input.hours.unwrap_or(24).clamp(1, 24 * 3650);
let lang = if input.lang.as_deref() == Some("pl") {
"pl"
} else {
"en"
};
let default_title = if lang == "pl" {
"Wykres niestandardowy"
} else {
"Custom chart"
};
let requested_lang = input.lang.as_deref().unwrap_or_default().trim();
let lang = LANGUAGE_ASSETS
.iter()
.find(|(code, _)| *code == requested_lang)
.map(|(code, _)| (*code).to_string())
.unwrap_or_else(|| DEFAULT_LANGUAGE_CODE.to_string());
let title = input
.title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(default_title)
.unwrap_or_default()
.chars()
.take(120)
.collect::<String>();
@@ -591,7 +580,7 @@ async fn create_public_custom_chart(
title,
series: input.series,
hours,
lang: lang.to_string(),
lang,
};
let token = generate_public_chart_token();
@@ -605,20 +594,19 @@ async fn create_public_custom_chart(
})))
}
fn public_chart_field_label(kind: &str, field: &str, lang: &str) -> Option<&'static str> {
let pl = lang == "pl";
fn public_chart_field_label_key(kind: &str, field: &str) -> Option<&'static str> {
match (kind, field) {
("device", "indoor") => Some(if pl { "Temperatura wewnętrzna" } else { "Indoor temperature" }),
("device", "outdoor") => Some(if pl { "Temperatura zewnętrzna GREE" } else { "GREE outdoor temperature" }),
("device", "target") => Some(if pl { "Temperatura zadana urządzenia" } else { "Device target" }),
("installation", "outdoor") => Some(if pl { "Wspólna temperatura zewnętrzna" } else { "Shared outdoor temperature" }),
("zone", "control") => Some(if pl { "Temperatura sterująca" } else { "Control temperature" }),
("zone", "gree") => Some(if pl { "Czujnik GREE" } else { "GREE sensor" }),
("zone", "external") => Some(if pl { "Czujnik pomieszczenia" } else { "Room sensor" }),
("zone", "target") => Some(if pl { "Temperatura docelowa" } else { "Comfort target" }),
("zone", "device_target") => Some(if pl { "Nastawa urządzenia" } else { "Device setpoint" }),
("zone", "outdoor") => Some(if pl { "Temperatura zewnętrzna" } else { "Outdoor temperature" }),
("ha", "temperature") => Some(if pl { "Temperatura" } else { "Temperature" }),
("device", "indoor") => Some("publicChart.field.deviceIndoor"),
("device", "outdoor") => Some("publicChart.field.deviceOutdoor"),
("device", "target") => Some("publicChart.field.deviceTarget"),
("installation", "outdoor") => Some("publicChart.field.sharedOutdoor"),
("zone", "control") => Some("publicChart.field.zoneControl"),
("zone", "gree") => Some("publicChart.field.greeSensor"),
("zone", "external") => Some("publicChart.field.roomSensor"),
("zone", "target") => Some("publicChart.field.comfortTarget"),
("zone", "device_target") => Some("publicChart.field.deviceSetpoint"),
("zone", "outdoor") => Some("publicChart.field.outdoor"),
("ha", "temperature") => Some("publicChart.field.temperature"),
_ => None,
}
}
@@ -632,7 +620,10 @@ fn reading_points(rows: Vec<Reading>, field: &str) -> Vec<PublicChartPoint> {
"target" => Some(row.target_temperature),
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
value.is_finite().then_some(PublicChartPoint {
timestamp: row.timestamp,
value,
})
})
.collect()
}
@@ -649,7 +640,10 @@ fn zone_reading_points(rows: Vec<ZoneReading>, field: &str) -> Vec<PublicChartPo
"outdoor" => row.outdoor_temperature,
_ => None,
}?;
value.is_finite().then_some(PublicChartPoint { timestamp: row.timestamp, value })
value.is_finite().then_some(PublicChartPoint {
timestamp: row.timestamp,
value,
})
})
.collect()
}
@@ -670,7 +664,11 @@ async fn public_custom_chart(
validate_public_chart_spec(&share.series)?;
let hours = query.hours.unwrap_or(share.hours).clamp(1, 24 * 3650);
let lang = if share.lang == "pl" { "pl" } else { "en" };
let lang = LANGUAGE_ASSETS
.iter()
.find(|(code, _)| *code == share.lang.as_str())
.map(|(code, _)| *code)
.unwrap_or(DEFAULT_LANGUAGE_CODE);
let since = Utc::now() - ChronoDuration::hours(hours);
let bucket_seconds = history_bucket_seconds(hours);
let limit = 20_000;
@@ -683,43 +681,73 @@ async fn public_custom_chart(
let kind = parts.next().unwrap_or_default();
let id = parts.next().unwrap_or_default();
let field = parts.next().unwrap_or_default();
let field_label = public_chart_field_label(kind, field, lang)
.ok_or_else(|| AppError::BadRequest(format!("unsupported custom chart series: {key}")))?;
let label_key = public_chart_field_label_key(kind, field).ok_or_else(|| {
AppError::BadRequest(format!("unsupported custom chart series: {key}"))
})?;
let item = match kind {
"device" => {
let device = state.db.get_device(id)?
let device = state
.db
.get_device(id)?
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
let (rows, _, _) = combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?;
let (rows, _, _) =
combined_device_history(&state, Some(id), since.clone(), bucket_seconds, limit)
.await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", device.name, field_label),
label: device.name,
label_key,
dashed: field == "target",
points: reading_points(rows, field),
}
}
"installation" => {
let group = state.db.get_device_group(id)?
let group = state
.db
.get_device_group(id)?
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
let representative = group.outdoor_temperature_device_id.as_deref()
.filter(|device_id| group.device_ids.iter().any(|member| member.as_str() == *device_id))
let representative = group
.outdoor_temperature_device_id
.as_deref()
.filter(|device_id| {
group
.device_ids
.iter()
.any(|member| member.as_str() == *device_id)
})
.or_else(|| group.device_ids.first().map(String::as_str))
.ok_or_else(|| AppError::BadRequest(format!("device group {id} has no devices")))?;
let (rows, _, _) = combined_device_history(&state, Some(representative), since.clone(), bucket_seconds, limit).await?;
.ok_or_else(|| {
AppError::BadRequest(format!("device group {id} has no devices"))
})?;
let (rows, _, _) = combined_device_history(
&state,
Some(representative),
since.clone(),
bucket_seconds,
limit,
)
.await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", group.name, field_label),
label: group.name,
label_key,
dashed: false,
points: reading_points(rows, "outdoor"),
}
}
"zone" => {
let zone = state.db.get_zone(id)?
let zone = state
.db
.get_zone(id)?
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let (rows, _, _) = combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit).await?;
let (rows, _, _) =
combined_zone_history(&state, Some(id), since.clone(), bucket_seconds, limit)
.await?;
PublicChartSeries {
key: key.clone(),
label: format!("{} · {}", zone.name, field_label),
label: zone.name,
label_key,
dashed: matches!(field, "target" | "device_target" | "outdoor"),
points: zone_reading_points(rows, field),
}
@@ -732,36 +760,40 @@ async fn public_custom_chart(
bucket_seconds,
limit,
&outdoor_entity,
).await?;
let alias = ha_settings.sensor_aliases.get(id)
)
.await?;
let alias = ha_settings
.sensor_aliases
.get(id)
.map(String::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(id);
PublicChartSeries {
key: key.clone(),
label: format!("HA · {} · {}", alias, field_label),
label: format!("HA · {}", alias),
label_key,
dashed: true,
points: rows.into_iter()
points: rows
.into_iter()
.filter(|row| row.entity_id == id && row.temperature.is_finite())
.map(|row| PublicChartPoint { timestamp: row.timestamp, value: row.temperature })
.map(|row| PublicChartPoint {
timestamp: row.timestamp,
value: row.temperature,
})
.collect(),
}
}
_ => return Err(AppError::BadRequest(format!("unsupported custom chart series: {key}"))),
_ => {
return Err(AppError::BadRequest(format!(
"unsupported custom chart series: {key}"
)))
}
};
series.push(item);
}
let (hint, no_data_label) = if lang == "pl" {
(format!("Ostatnie {hours} h"), "Brak danych")
} else {
(format!("Last {hours} h"), "No data")
};
Ok(Json(json!({
"title": share.title,
"hint": hint,
"no_data_label": no_data_label,
"lang": lang,
"hours": hours,
"bucket_seconds": bucket_seconds,
@@ -814,7 +846,12 @@ async fn energy_history(
"previous_day" => Some(1),
"previous_period" => Some(days),
"previous_year" => Some(365),
_ => return Err(AppError::BadRequest("energy compare must be none, previous_day, previous_period or previous_year".into())),
_ => {
return Err(AppError::BadRequest(
"energy compare must be none, previous_day, previous_period or previous_year"
.into(),
))
}
};
let requested_source = query.source.as_deref().unwrap_or("auto");
@@ -824,66 +861,75 @@ async fn energy_history(
));
}
let (target_type, public_target_id, target_name, member_device_ids, configured_source, storage_id, preferred_source) =
if let Some(group_id) = target_id.strip_prefix("group:") {
let group = state
.db
.get_device_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?;
let auto_source = if group.energy_device_id.is_some() {
Some("gree_cloud")
} else if group.ha_energy_entity_id.is_some() {
Some("home_assistant")
} else {
None
};
let selected = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
_ => match group.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => auto_source,
},
};
let storage_id = match selected {
Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| AppError::BadRequest("installation has no GREE Cloud energy source device".into()))?,
_ => format!("group:{}", group.id),
};
(
"group",
format!("group:{}", group.id),
group.name,
group.device_ids,
group.energy_source,
storage_id,
selected,
)
let (
target_type,
public_target_id,
target_name,
member_device_ids,
configured_source,
storage_id,
preferred_source,
) = if let Some(group_id) = target_id.strip_prefix("group:") {
let group = state
.db
.get_device_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("device group {group_id}")))?;
let auto_source = if group.energy_device_id.is_some() {
Some("gree_cloud")
} else if group.ha_energy_entity_id.is_some() {
Some("home_assistant")
} else {
let device = state
.db
.get_device(target_id)?
.ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?;
let selected = 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 => None,
},
_ => None,
};
(
"device",
device.id.clone(),
device.name,
vec![device.id.clone()],
device.energy_source,
device.id,
selected,
)
None
};
let selected = match requested_source {
"gree_cloud" => Some("gree_cloud"),
"home_assistant" => Some("home_assistant"),
_ => match group.energy_source {
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto => auto_source,
},
};
let storage_id = match selected {
Some("gree_cloud") => group.energy_device_id.clone().ok_or_else(|| {
AppError::BadRequest("installation has no GREE Cloud energy source device".into())
})?,
_ => format!("group:{}", group.id),
};
(
"group",
format!("group:{}", group.id),
group.name,
group.device_ids,
group.energy_source,
storage_id,
selected,
)
} else {
let device = state
.db
.get_device(target_id)?
.ok_or_else(|| AppError::NotFound(format!("device {target_id}")))?;
let selected = 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 => None,
},
_ => None,
};
(
"device",
device.id.clone(),
device.name,
vec![device.id.clone()],
device.energy_source,
device.id,
selected,
)
};
let now = Utc::now();
let since = now - ChronoDuration::days(days);
@@ -943,11 +989,15 @@ async fn energy_history(
"InfluxDB energy history query failed",
json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}),
);
state.db.list_energy_readings(&storage_id, load_since, limit)?
state
.db
.list_energy_readings(&storage_id, load_since, limit)?
}
}
} else {
state.db.list_energy_readings(&storage_id, load_since, limit)?
state
.db
.list_energy_readings(&storage_id, load_since, limit)?
};
samples.sort_by_key(|row| row.timestamp);
@@ -976,18 +1026,27 @@ async fn energy_history(
Utc,
)
}
fn bucket_start(timestamp: chrono::DateTime<Utc>, interval_name: &str) -> chrono::DateTime<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"),
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"))
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")),
"monthly" => midnight(
NaiveDate::from_ymd_opt(date.year(), date.month(), 1).expect("valid month"),
),
_ => midnight(date),
}
}
@@ -999,18 +1058,26 @@ async fn energy_history(
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
for sample in rows {
let shifted = sample.timestamp + ChronoDuration::days(shift_days);
*buckets.entry(bucket_start(shifted, interval_name)).or_default() += sample.consumption_kwh.max(0.0);
*buckets
.entry(bucket_start(shifted, interval_name))
.or_default() += sample.consumption_kwh.max(0.0);
}
buckets.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect()
}
let period_samples = samples.iter().filter(|row| row.timestamp >= since && row.timestamp <= now).collect::<Vec<_>>();
let period_samples = samples
.iter()
.filter(|row| row.timestamp >= since && row.timestamp <= now)
.collect::<Vec<_>>();
let buckets = bucket_rows(period_samples.iter().copied(), interval_name, 0);
let comparison = if let Some(shift) = compare_shift_days {
let compare_end = now - ChronoDuration::days(shift);
let compare_start = since - ChronoDuration::days(shift);
let rows = samples.iter().filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end).collect::<Vec<_>>();
let rows = samples
.iter()
.filter(|row| row.timestamp >= compare_start && row.timestamp <= compare_end)
.collect::<Vec<_>>();
let total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum();
Some(json!({
"kind": compare_name,
@@ -1026,13 +1093,24 @@ async fn energy_history(
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 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 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()
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 period_total: f64 = period_samples
.iter()
.map(|row| row.consumption_kwh.max(0.0))
.sum();
let latest = samples.last().cloned();
Ok(Json(json!({
@@ -1079,7 +1157,9 @@ async fn combined_network_history(
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff {
return Ok((
state.db.list_network_history(target_id, since, bucket_seconds, limit)?,
state
.db
.list_network_history(target_id, since, bucket_seconds, limit)?,
"sqlite".into(),
None,
));
@@ -1105,17 +1185,27 @@ async fn combined_network_history(
"InfluxDB connectivity history query failed",
json!({"target_id": target_id, "error": err.to_string()}),
);
state.db.list_network_history(target_id, since, bucket_seconds, limit)?
state
.db
.list_network_history(target_id, since, bucket_seconds, limit)?
}
};
if warning.is_none() {
values.extend(state.db.list_network_history(target_id, cutoff, bucket_seconds, limit)?);
values.extend(
state
.db
.list_network_history(target_id, cutoff, bucket_seconds, limit)?,
);
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
Ok((
values,
if warning.is_some() { "sqlite_fallback".into() } else { "influx+sqlite".into() },
if warning.is_some() {
"sqlite_fallback".into()
} else {
"influx+sqlite".into()
},
warning,
))
}
@@ -1149,10 +1239,12 @@ async fn network_history(
}
let has_rest = readings.iter().any(|row| row.target_id == "cloud:rest");
let has_mqtt = readings.iter().any(|row| row.target_id == "cloud:mqtt");
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_rest {
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_rest
{
targets.push(json!({"id":"cloud:rest","name":"GREE Cloud REST","kind":"cloud_service","source":"cloud_rest"}));
}
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_mqtt {
if (settings.gree_cloud.enabled && settings.gree_cloud.connectivity_metrics_enabled) || has_mqtt
{
targets.push(json!({"id":"cloud:mqtt","name":"GREE Cloud MQTT","kind":"cloud_service","source":"cloud_mqtt"}));
}
Ok(Json(json!({
+7 -7
View File
@@ -29,9 +29,7 @@ fn map_notification_test_error(message: String) -> AppError {
}
}
async fn home_assistant_snapshot(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
async fn home_assistant_snapshot(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let control_plan = engine::get_control_plan_snapshot(&state).await?;
let groups = list_home_assistant_groups(State(state.clone())).await?.0;
Ok(Json(json!({
@@ -42,9 +40,7 @@ async fn home_assistant_snapshot(
})))
}
async fn test_home_assistant(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
async fn test_home_assistant(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let settings = state.settings.read().await.clone();
let sample = home_assistant::test_connection(&state.http, &settings.home_assistant)
.await
@@ -92,7 +88,11 @@ async fn list_home_assistant_entities(
a.get("entity_id")
.and_then(Value::as_str)
.unwrap_or_default()
.cmp(b.get("entity_id").and_then(Value::as_str).unwrap_or_default())
.cmp(
b.get("entity_id")
.and_then(Value::as_str)
.unwrap_or_default(),
)
});
Ok(Json(json!({
"configured": true,
+24 -6
View File
@@ -28,7 +28,9 @@ fn gree_cloud_settings(settings: &RuntimeSettings) -> GreeCloudSettingsView {
password_configured: !settings.gree_cloud.password.is_empty(),
polling_interval_seconds: settings.gree_cloud.polling_interval_seconds,
connectivity_metrics_enabled: settings.gree_cloud.connectivity_metrics_enabled,
connectivity_metrics_interval_seconds: settings.gree_cloud.connectivity_metrics_interval_seconds,
connectivity_metrics_interval_seconds: settings
.gree_cloud
.connectivity_metrics_interval_seconds,
connectivity_metrics_sample_count: settings.gree_cloud.connectivity_metrics_sample_count,
installation_id: settings.gree_cloud.installation_id.clone(),
account_id: settings.gree_cloud.account_id.clone(),
@@ -243,10 +245,7 @@ async fn update_gree_settings(
Ok(Json(payload))
}
async fn get_gree_cloud_settings(
State(state): State<AppState>,
) -> Json<GreeCloudSettingsView> {
async fn get_gree_cloud_settings(State(state): State<AppState>) -> Json<GreeCloudSettingsView> {
Json(gree_cloud_settings(&*state.settings.read().await))
}
@@ -266,7 +265,8 @@ fn apply_gree_cloud_update(
next.username = input.username.trim().to_string();
next.polling_interval_seconds = input.polling_interval_seconds.clamp(30, 3600);
next.connectivity_metrics_enabled = input.connectivity_metrics_enabled;
next.connectivity_metrics_interval_seconds = input.connectivity_metrics_interval_seconds.clamp(30, 3600);
next.connectivity_metrics_interval_seconds =
input.connectivity_metrics_interval_seconds.clamp(30, 3600);
next.connectivity_metrics_sample_count = input.connectivity_metrics_sample_count.clamp(1, 10);
if let Some(password) = input.password {
next.password = password;
@@ -365,6 +365,24 @@ async fn get_influxdb_settings(State(state): State<AppState>) -> Json<InfluxDbSe
Json(influxdb_settings(&*state.settings.read().await))
}
async fn test_influxdb_connection(
State(state): State<AppState>,
Json(mut input): Json<InfluxDbSettingsUpdate>,
) -> Result<Json<Value>, AppError> {
input.enabled = true;
let current = state.settings.read().await.influxdb.clone();
let settings = apply_influxdb_update(&current, input)?;
let started = Instant::now();
influxdb::test_connection(&state.http, &settings)
.await
.map_err(|err| AppError::BadRequest(err.to_string()))?;
Ok(Json(json!({
"ok": true,
"version": settings.version,
"response_time_ms": started.elapsed().as_millis(),
})))
}
fn apply_influxdb_update(
current: &InfluxDbSettings,
input: InfluxDbSettingsUpdate,
+9 -10
View File
@@ -38,7 +38,9 @@ fn device_group_energy_snapshot(
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
EnergySourcePreference::Auto if group.energy_device_id.is_some() => Some("gree_cloud"),
EnergySourcePreference::Auto if group.ha_energy_entity_id.is_some() => Some("home_assistant"),
EnergySourcePreference::Auto if group.ha_energy_entity_id.is_some() => {
Some("home_assistant")
}
EnergySourcePreference::Auto => None,
};
@@ -108,10 +110,7 @@ struct BootstrapResponse {
system: SystemInfoResponse,
}
async fn health(
State(state): State<AppState>,
request: Request,
) -> Result<Json<Value>, AppError> {
async fn health(State(state): State<AppState>, request: Request) -> Result<Json<Value>, AppError> {
if home_assistant::supervisor_token_detected() {
let trusted_supervisor = request
.extensions()
@@ -176,11 +175,13 @@ 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.providers.local().client().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(),
auth_required: !state.config.app_token.trim().is_empty() || home_assistant::supervisor_token_detected(),
auth_required: !state.config.app_token.trim().is_empty()
|| home_assistant::supervisor_token_detected(),
control_ready: state.initial_device_sync_complete.load(Ordering::Acquire),
database: state.config.database.display().to_string(),
device_count: devices.len(),
@@ -203,9 +204,7 @@ fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse
}
}
async fn system_info(
State(state): State<AppState>,
) -> Result<Json<SystemInfoResponse>, AppError> {
async fn system_info(State(state): State<AppState>) -> Result<Json<SystemInfoResponse>, AppError> {
let devices = state.db.list_devices()?;
Ok(Json(build_system_info(&state, &devices)))
}