v0.14.21
This commit is contained in:
+12
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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
@@ -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(¤t, 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
@@ -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)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user