v0.14.21
This commit is contained in:
+25
-19
@@ -4,16 +4,16 @@ use crate::{
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport,
|
||||
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup, DeviceGroupKind, DevicePatch, DiscoveryRequest,
|
||||
EnergyReading, EnergySourcePreference, Flow, GreeSettings,
|
||||
GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView,
|
||||
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup,
|
||||
DeviceGroupKind, DevicePatch, DiscoveryRequest, EnergyReading, EnergySourcePreference,
|
||||
Flow, GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, GreeSettings,
|
||||
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
|
||||
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
|
||||
InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings,
|
||||
NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, NetworkReading, Reading,
|
||||
RuntimeSettings, Schedule, SettingsSnapshot, TemporaryQuickThermostat,
|
||||
TemporaryQuickThermostatRequest, Zone,
|
||||
ZoneControlPatch, ZoneReading,
|
||||
InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NetworkReading,
|
||||
NightModeSettings, NotificationSettings, NotificationSettingsUpdate,
|
||||
NotificationSettingsView, Reading, RuntimeSettings, Schedule, SettingsSnapshot,
|
||||
TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch,
|
||||
ZoneReading,
|
||||
},
|
||||
notifications,
|
||||
protocol::merge_discovered,
|
||||
@@ -50,14 +50,14 @@ mod openapi;
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||
const CUSTOM_CHART_HTML: &str = include_str!("../web/custom-chart.html");
|
||||
const CUSTOM_CHART_JS: &str = include_str!("../web/custom-chart.js");
|
||||
const CUSTOM_CHART_JS: &str = include_str!("../web/js/custom-chart.js");
|
||||
const NOT_FOUND_HTML: &str = include_str!("../web/404.html");
|
||||
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js"));
|
||||
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
|
||||
const LANG_INIT_JS: &str = include_str!("../web/lang-init.js");
|
||||
const STYLES_CSS: &str = include_str!("../web/styles.css");
|
||||
const THEME_INIT_JS: &str = include_str!("../web/js/theme-init.js");
|
||||
const LANG_INIT_JS: &str = include_str!("../web/js/lang-init.js");
|
||||
const STYLES_CSS: &str = include_str!("../web/css/styles.css");
|
||||
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
|
||||
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
|
||||
const SERVICE_WORKER: &str = include_str!("../web/js/sw.js");
|
||||
const FAVICON: &str = include_str!("../web/favicon.svg");
|
||||
include!(concat!(env!("OUT_DIR"), "/languages.rs"));
|
||||
|
||||
@@ -98,10 +98,15 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/devices/:id/poll", post(poll_device))
|
||||
.route("/api/devices/:id/probe", post(probe_device))
|
||||
.route("/api/devices/:id/command", post(command_device))
|
||||
.route("/api/device-groups", get(list_device_groups).post(create_device_group))
|
||||
.route(
|
||||
"/api/device-groups",
|
||||
get(list_device_groups).post(create_device_group),
|
||||
)
|
||||
.route(
|
||||
"/api/device-groups/:id",
|
||||
get(get_device_group).put(update_device_group).delete(delete_device_group),
|
||||
get(get_device_group)
|
||||
.put(update_device_group)
|
||||
.delete(delete_device_group),
|
||||
)
|
||||
.route("/api/zones", get(list_zones).post(create_zone))
|
||||
.route(
|
||||
@@ -183,6 +188,10 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/settings/influxdb",
|
||||
get(get_influxdb_settings).put(update_influxdb_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/influxdb/test",
|
||||
post(test_influxdb_connection),
|
||||
)
|
||||
.route(
|
||||
"/api/settings/notifications",
|
||||
get(get_notification_settings).put(update_notification_settings),
|
||||
@@ -209,10 +218,7 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/access-tokens/:id",
|
||||
axum::routing::delete(delete_access_token),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/gree-cloud/test",
|
||||
post(test_gree_cloud),
|
||||
)
|
||||
.route("/api/integrations/gree-cloud/test", post(test_gree_cloud))
|
||||
.route(
|
||||
"/api/integrations/gree-cloud/devices",
|
||||
get(discover_gree_cloud_devices),
|
||||
|
||||
+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)))
|
||||
}
|
||||
|
||||
+13
-4
@@ -21,7 +21,11 @@ pub struct Config {
|
||||
pub app_token: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")]
|
||||
pub base_path: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL", default_value = "")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL",
|
||||
default_value = ""
|
||||
)]
|
||||
pub public_chart_base_url: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
|
||||
pub simulate: bool,
|
||||
@@ -62,7 +66,8 @@ impl Config {
|
||||
dotenvy::dotenv().ok();
|
||||
let mut config = Self::parse();
|
||||
config.base_path = normalize_base_path(&config.base_path)?;
|
||||
config.public_chart_base_url = normalize_public_chart_base_url(&config.public_chart_base_url)?;
|
||||
config.public_chart_base_url =
|
||||
normalize_public_chart_base_url(&config.public_chart_base_url)?;
|
||||
if let Some(parent) = config.database.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("cannot create database directory {}", parent.display())
|
||||
@@ -80,8 +85,12 @@ impl Config {
|
||||
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
|
||||
discovery_broadcast: self.discovery_broadcast.clone(),
|
||||
ping_metrics_enabled: env_bool("GREE_CONTROLLER_PING_METRICS_ENABLED").unwrap_or(true),
|
||||
ping_interval_seconds: env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS").unwrap_or(60).clamp(10, 3600),
|
||||
ping_sample_count: env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT").unwrap_or(3).clamp(1, 10),
|
||||
ping_interval_seconds: env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS")
|
||||
.unwrap_or(60)
|
||||
.clamp(10, 3600),
|
||||
ping_sample_count: env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT")
|
||||
.unwrap_or(3)
|
||||
.clamp(1, 10),
|
||||
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
|
||||
control_strategy: "setpoint".into(),
|
||||
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::{
|
||||
models::{
|
||||
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, DeviceGroup, EnergySourcePreference, EventLog, Flow,
|
||||
EnergyReading, HaReading, NetworkReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
|
||||
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device,
|
||||
DeviceGroup, EnergyReading, EnergySourcePreference, EventLog, Flow, HaReading,
|
||||
NetworkReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
|
||||
},
|
||||
queries,
|
||||
};
|
||||
|
||||
+4
-1
@@ -68,7 +68,10 @@ impl Db {
|
||||
}
|
||||
|
||||
pub fn device_group_for_device(&self, device_id: &str) -> Result<Option<DeviceGroup>> {
|
||||
Ok(self.list_device_groups()?.into_iter().find(|group| group.device_ids.iter().any(|id| id == device_id)))
|
||||
Ok(self
|
||||
.list_device_groups()?
|
||||
.into_iter()
|
||||
.find(|group| group.device_ids.iter().any(|id| id == device_id)))
|
||||
}
|
||||
|
||||
pub fn delete_device_group(&self, id: &str) -> Result<bool> {
|
||||
|
||||
@@ -22,7 +22,10 @@ impl Db {
|
||||
let payload = Self::device_to_storage_json(device)?;
|
||||
let index_mac = match device.connection_type {
|
||||
ConnectionType::Local => device.mac.clone(),
|
||||
ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)),
|
||||
ConnectionType::GreeCloud => format!(
|
||||
"cloud:{}",
|
||||
device.cloud_device_id.as_deref().unwrap_or(&device.mac)
|
||||
),
|
||||
};
|
||||
tx.execute(
|
||||
queries::UPSERT_DEVICE,
|
||||
@@ -55,7 +58,11 @@ impl Db {
|
||||
let payload = Self::to_json(device_group)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_DEVICE_GROUP,
|
||||
params![device_group.id, payload, device_group.updated_at.to_rfc3339()],
|
||||
params![
|
||||
device_group.id,
|
||||
payload,
|
||||
device_group.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for schedule in &export.schedules {
|
||||
|
||||
+10
-3
@@ -45,7 +45,10 @@ impl Db {
|
||||
let payload = Self::device_to_storage_json(device)?;
|
||||
let index_mac = match device.connection_type {
|
||||
ConnectionType::Local => device.mac.clone(),
|
||||
ConnectionType::GreeCloud => format!("cloud:{}", device.cloud_device_id.as_deref().unwrap_or(&device.mac)),
|
||||
ConnectionType::GreeCloud => format!(
|
||||
"cloud:{}",
|
||||
device.cloud_device_id.as_deref().unwrap_or(&device.mac)
|
||||
),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
@@ -90,7 +93,9 @@ impl Db {
|
||||
|
||||
pub fn delete_device(&self, id: &str) -> Result<bool> {
|
||||
for mut group in self.list_device_groups()? {
|
||||
if !group.device_ids.iter().any(|device_id| device_id == id) { continue; }
|
||||
if !group.device_ids.iter().any(|device_id| device_id == id) {
|
||||
continue;
|
||||
}
|
||||
group.device_ids.retain(|device_id| device_id != id);
|
||||
if group.energy_device_id.as_deref() == Some(id) {
|
||||
group.energy_device_id = None;
|
||||
@@ -98,7 +103,9 @@ impl Db {
|
||||
group.energy_source = EnergySourcePreference::Auto;
|
||||
}
|
||||
}
|
||||
if group.outdoor_temperature_device_id.as_deref() == Some(id) { group.outdoor_temperature_device_id = None; }
|
||||
if group.outdoor_temperature_device_id.as_deref() == Some(id) {
|
||||
group.outdoor_temperature_device_id = None;
|
||||
}
|
||||
if group.device_ids.is_empty() {
|
||||
self.delete_device_group(&group.id)?;
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,11 @@ impl Db {
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn last_energy_reading(&self, device_id: &str, source: &str) -> Result<Option<EnergyReading>> {
|
||||
pub fn last_energy_reading(
|
||||
&self,
|
||||
device_id: &str,
|
||||
source: &str,
|
||||
) -> Result<Option<EnergyReading>> {
|
||||
let conn = self.lock()?;
|
||||
conn.query_row(
|
||||
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND source=?2 ORDER BY timestamp DESC,id DESC LIMIT 1",
|
||||
@@ -39,10 +43,15 @@ impl Db {
|
||||
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND timestamp>=?2 ORDER BY timestamp ASC,id ASC LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![device_id, since.to_rfc3339(), limit.clamp(1, 100_000) as i64],
|
||||
params![
|
||||
device_id,
|
||||
since.to_rfc3339(),
|
||||
limit.clamp(1, 100_000) as i64
|
||||
],
|
||||
Self::map_energy_reading,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn energy_before(&self, before: DateTime<Utc>, limit: u32) -> Result<Vec<EnergyReading>> {
|
||||
@@ -54,7 +63,8 @@ impl Db {
|
||||
params![before.to_rfc3339(), limit.clamp(1, 5000) as i64],
|
||||
Self::map_energy_reading,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn delete_energy_batch(&self, rows: &[EnergyReading]) -> Result<u64> {
|
||||
@@ -71,7 +81,10 @@ impl Db {
|
||||
pub fn prune_energy_readings(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute("DELETE FROM energy_readings WHERE timestamp < ?1", [before.to_rfc3339()])? as u64)
|
||||
Ok(conn.execute(
|
||||
"DELETE FROM energy_readings WHERE timestamp < ?1",
|
||||
[before.to_rfc3339()],
|
||||
)? as u64)
|
||||
}
|
||||
|
||||
fn map_energy_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<EnergyReading> {
|
||||
|
||||
@@ -126,7 +126,9 @@ impl Db {
|
||||
pub fn get_public_chart_share(&self, token_hash: &str) -> Result<Option<Value>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn
|
||||
.query_row(queries::GET_PUBLIC_CHART_SHARE, [token_hash], |row| row.get(0))
|
||||
.query_row(queries::GET_PUBLIC_CHART_SHARE, [token_hash], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
payload
|
||||
.map(|value| serde_json::from_str(&value))
|
||||
|
||||
@@ -53,14 +53,18 @@ LIMIT ?3
|
||||
params![target_id, since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_network_reading,
|
||||
)?;
|
||||
for row in rows { out.push(row?); }
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(sql_all)?;
|
||||
let rows = stmt.query_map(
|
||||
params![since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_network_reading,
|
||||
)?;
|
||||
for row in rows { out.push(row?); }
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -74,7 +78,8 @@ LIMIT ?3
|
||||
params![before.to_rfc3339(), limit.clamp(1, 5000) as i64],
|
||||
Self::map_network_reading,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn delete_network_batch(&self, rows: &[NetworkReading]) -> Result<u64> {
|
||||
@@ -91,7 +96,10 @@ LIMIT ?3
|
||||
pub fn prune_network_readings(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute("DELETE FROM network_readings WHERE timestamp < ?1", [before.to_rfc3339()])? as u64)
|
||||
Ok(conn.execute(
|
||||
"DELETE FROM network_readings WHERE timestamp < ?1",
|
||||
[before.to_rfc3339()],
|
||||
)? as u64)
|
||||
}
|
||||
|
||||
pub fn compact_network_history(&self, retention_days: i64) -> Result<u64> {
|
||||
@@ -116,8 +124,13 @@ DELETE FROM network_readings WHERE id IN (
|
||||
(600_i64, one_day, seven_days),
|
||||
(1800_i64, seven_days, retention),
|
||||
] {
|
||||
if older_than <= newer_than { continue; }
|
||||
changed += conn.execute(sql, params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()])? as u64;
|
||||
if older_than <= newer_than {
|
||||
continue;
|
||||
}
|
||||
changed += conn.execute(
|
||||
sql,
|
||||
params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()],
|
||||
)? as u64;
|
||||
}
|
||||
conn.execute_batch("PRAGMA optimize;")?;
|
||||
Ok(changed)
|
||||
|
||||
+12
-5
@@ -86,12 +86,16 @@ mod tests {
|
||||
"hours": 24,
|
||||
"lang": "en"
|
||||
});
|
||||
db.save_public_chart_share("chart-hash", &chart_share).unwrap();
|
||||
db.save_public_chart_share("chart-hash", &chart_share)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_public_chart_share("chart-hash").unwrap(),
|
||||
Some(chart_share)
|
||||
);
|
||||
assert!(db.get_public_chart_share("missing-chart").unwrap().is_none());
|
||||
assert!(db
|
||||
.get_public_chart_share("missing-chart")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
let now = Utc::now();
|
||||
db.add_reading(&Reading {
|
||||
@@ -162,8 +166,11 @@ mod tests {
|
||||
db.save_device(&cloud).unwrap();
|
||||
let devices = db.list_devices().unwrap();
|
||||
assert_eq!(devices.len(), 2);
|
||||
assert!(devices.iter().any(|item| item.connection_type == ConnectionType::Local));
|
||||
assert!(devices.iter().any(|item| item.connection_type == ConnectionType::GreeCloud));
|
||||
assert!(devices
|
||||
.iter()
|
||||
.any(|item| item.connection_type == ConnectionType::Local));
|
||||
assert!(devices
|
||||
.iter()
|
||||
.any(|item| item.connection_type == ConnectionType::GreeCloud));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+5
-3
@@ -2,9 +2,11 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, DeviceGroup, EnergyReading, EnergySourcePreference,
|
||||
DeviceCommand, GroupControlPatch, HaReading, NetworkReading, NightModeSettings, Reading, RuntimeSettings,
|
||||
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
|
||||
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType,
|
||||
ControlPlan, ControlPlanEvent, Device, DeviceCommand, DeviceGroup, EnergyReading,
|
||||
EnergySourcePreference, GroupControlPatch, HaReading, NetworkReading, NightModeSettings,
|
||||
Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan,
|
||||
ZoneReading,
|
||||
},
|
||||
state::{AppState, ControlPlanSnapshot, PendingControllerCommand},
|
||||
};
|
||||
|
||||
+37
-14
@@ -81,7 +81,12 @@ async fn send_command_locked_inner(
|
||||
}
|
||||
}
|
||||
}
|
||||
match state.providers.local().command(&device, &command, suppress_beep).await {
|
||||
match state
|
||||
.providers
|
||||
.local()
|
||||
.command(&device, &command, suppress_beep)
|
||||
.await
|
||||
{
|
||||
Ok(result) => applied_command = result,
|
||||
Err(first_err) => {
|
||||
// A lost command ACK does not mean the command was lost. Read the device
|
||||
@@ -101,7 +106,11 @@ async fn send_command_locked_inner(
|
||||
if remaining.is_empty() {
|
||||
Ok(command.clone())
|
||||
} else {
|
||||
state.providers.local().command(&device, &remaining, suppress_beep).await
|
||||
state
|
||||
.providers
|
||||
.local()
|
||||
.command(&device, &remaining, suppress_beep)
|
||||
.await
|
||||
}
|
||||
}
|
||||
Err(_) => match state.providers.local().bind(&device).await {
|
||||
@@ -109,7 +118,11 @@ async fn send_command_locked_inner(
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
state.db.save_device(&device)?;
|
||||
state.providers.local().command(&device, &command, suppress_beep).await
|
||||
state
|
||||
.providers
|
||||
.local()
|
||||
.command(&device, &command, suppress_beep)
|
||||
.await
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
},
|
||||
@@ -277,7 +290,10 @@ async fn send_cloud_command_locked_inner(
|
||||
}
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
state.broadcast(
|
||||
"device.updated",
|
||||
serde_json::to_value(&device).unwrap_or_default(),
|
||||
);
|
||||
|
||||
let mut transport_device = baseline.clone();
|
||||
let applied = match state
|
||||
@@ -298,7 +314,10 @@ async fn send_cloud_command_locked_inner(
|
||||
restored.pending_command = false;
|
||||
register_cloud_failure(&mut restored, &err.to_string());
|
||||
state.db.save_device(&restored)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&restored).unwrap_or_default());
|
||||
state.broadcast(
|
||||
"device.updated",
|
||||
serde_json::to_value(&restored).unwrap_or_default(),
|
||||
);
|
||||
state.log(
|
||||
"warn",
|
||||
"gree_cloud.command_rejected",
|
||||
@@ -315,12 +334,8 @@ async fn send_cloud_command_locked_inner(
|
||||
// MQTT push is the primary confirmation path and one delayed recovery read is scheduled.
|
||||
let mut accepted = transport_device;
|
||||
accepted.pending_command = true;
|
||||
accepted.response_time_ms = Some(
|
||||
response_started
|
||||
.elapsed()
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64,
|
||||
);
|
||||
accepted.response_time_ms =
|
||||
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
accepted.updated_at = Utc::now();
|
||||
accepted.refresh_capabilities();
|
||||
state.db.save_device(&accepted)?;
|
||||
@@ -351,7 +366,8 @@ async fn send_cloud_command_locked_inner(
|
||||
let recovery_device_id = accepted.id.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(1500)).await;
|
||||
let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id) else {
|
||||
let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id)
|
||||
else {
|
||||
tracing::debug!(device=%recovery_device_id, "skipping duplicate GREE Cloud recovery poll");
|
||||
return;
|
||||
};
|
||||
@@ -415,9 +431,16 @@ fn register_cloud_failure(device: &mut Device, error: &str) {
|
||||
|
||||
fn cloud_connection_status(error: &str) -> ConnectionStatus {
|
||||
let error = error.to_ascii_lowercase();
|
||||
if error.contains("authentication") || error.contains("not authorized") || error.contains("invalid username") {
|
||||
if error.contains("authentication")
|
||||
|| error.contains("not authorized")
|
||||
|| error.contains("invalid username")
|
||||
{
|
||||
ConnectionStatus::AuthenticationError
|
||||
} else if error.contains("mqtt") || error.contains("connect") || error.contains("tls") || error.contains("network") {
|
||||
} else if error.contains("mqtt")
|
||||
|| error.contains("connect")
|
||||
|| error.contains("tls")
|
||||
|| error.contains("network")
|
||||
{
|
||||
ConnectionStatus::CloudDisconnected
|
||||
} else {
|
||||
ConnectionStatus::Offline
|
||||
|
||||
@@ -16,7 +16,11 @@ fn build_network_reading(
|
||||
Some(successful.iter().sum::<f64>() / successful.len() as f64)
|
||||
};
|
||||
let jitter_ms = if successful.len() < 2 {
|
||||
if successful.is_empty() { None } else { Some(0.0) }
|
||||
if successful.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0.0)
|
||||
}
|
||||
} else {
|
||||
let diffs = successful
|
||||
.windows(2)
|
||||
@@ -46,7 +50,9 @@ fn build_network_reading(
|
||||
fn save_network_reading(state: &AppState, reading: NetworkReading) {
|
||||
match state.db.add_network_reading(&reading) {
|
||||
Ok(_) => queue_influx_network(state, reading),
|
||||
Err(err) => tracing::warn!(error=?err, target_id=%reading.target_id, "cannot save connectivity metric"),
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, target_id=%reading.target_id, "cannot save connectivity metric")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +83,7 @@ async fn run_local_connectivity_cycle(state: &AppState, sample_count: u32) -> Re
|
||||
.list_devices()?
|
||||
.into_iter()
|
||||
.filter(|device| {
|
||||
device.enabled
|
||||
&& !device.simulated
|
||||
&& device.connection_type == ConnectionType::Local
|
||||
device.enabled && !device.simulated && device.connection_type == ConnectionType::Local
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
futures_util::future::join_all(
|
||||
@@ -91,7 +95,10 @@ async fn run_local_connectivity_cycle(state: &AppState, sample_count: u32) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cloud_rest_probe(state: &AppState, cloud: &crate::models::GreeCloudSettings) -> Result<u64> {
|
||||
async fn cloud_rest_probe(
|
||||
state: &AppState,
|
||||
cloud: &crate::models::GreeCloudSettings,
|
||||
) -> Result<u64> {
|
||||
let mut api = crate::protocol::gree_cloud::GreeCloudApi::for_region(
|
||||
state.http.clone(),
|
||||
&cloud.region,
|
||||
@@ -149,10 +156,15 @@ async fn local_connectivity_loop(state: AppState) {
|
||||
loop {
|
||||
let settings = state.settings.read().await.clone();
|
||||
if settings.ping_metrics_enabled {
|
||||
if let Err(err) = run_local_connectivity_cycle(&state, settings.ping_sample_count.clamp(1, 10)).await {
|
||||
if let Err(err) =
|
||||
run_local_connectivity_cycle(&state, settings.ping_sample_count.clamp(1, 10)).await
|
||||
{
|
||||
tracing::warn!(error=?err, "local connectivity metrics cycle failed");
|
||||
}
|
||||
sleep(Duration::from_secs(settings.ping_interval_seconds.clamp(10, 3600))).await;
|
||||
sleep(Duration::from_secs(
|
||||
settings.ping_interval_seconds.clamp(10, 3600),
|
||||
))
|
||||
.await;
|
||||
} else {
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
}
|
||||
|
||||
@@ -442,8 +442,8 @@ fn next_schedule_events(
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let minute_of_day = candidate.hour() * 60 + candidate.minute();
|
||||
let previous_local = base + chrono::Duration::minutes(minute - 1);
|
||||
let clock_discontinuity = candidate.naive_local() - previous_local.naive_local()
|
||||
!= chrono::Duration::minutes(1);
|
||||
let clock_discontinuity =
|
||||
candidate.naive_local() - previous_local.naive_local() != chrono::Duration::minutes(1);
|
||||
if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity {
|
||||
continue;
|
||||
}
|
||||
|
||||
+100
-25
@@ -1,6 +1,9 @@
|
||||
const ENERGY_ANOMALY_MAX_DELTA_KWH: f64 = 100.0;
|
||||
|
||||
fn cumulative_energy_delta(previous_kwh: Option<f64>, current_kwh: f64) -> (f64, &'static str, bool) {
|
||||
fn cumulative_energy_delta(
|
||||
previous_kwh: Option<f64>,
|
||||
current_kwh: f64,
|
||||
) -> (f64, &'static str, bool) {
|
||||
let Some(previous_kwh) = previous_kwh else {
|
||||
return (0.0, "baseline", false);
|
||||
};
|
||||
@@ -18,13 +21,17 @@ fn cumulative_energy_delta(previous_kwh: Option<f64>, current_kwh: f64) -> (f64,
|
||||
|
||||
fn normalize_energy_kwh(raw_value: f64, unit: &str) -> Result<f64, AppError> {
|
||||
if !raw_value.is_finite() || raw_value < 0.0 {
|
||||
return Err(AppError::BadRequest("energy meter value must be a finite non-negative number".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"energy meter value must be a finite non-negative number".into(),
|
||||
));
|
||||
}
|
||||
match unit.trim().to_ascii_lowercase().as_str() {
|
||||
"kwh" => Ok(raw_value),
|
||||
"wh" => Ok(raw_value / 1000.0),
|
||||
"0.1kwh" | "0.1 kwh" => Ok(raw_value * 0.1),
|
||||
other => Err(AppError::BadRequest(format!("unsupported energy unit: {other}"))),
|
||||
other => Err(AppError::BadRequest(format!(
|
||||
"unsupported energy unit: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,37 +80,77 @@ fn queue_influx_energy(state: &AppState, reading: EnergyReading) {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let settings = state.settings.read().await.influxdb.clone();
|
||||
if !settings.enabled { return; }
|
||||
if !settings.enabled {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = influxdb::write_energy(&state.http, &settings, &reading).await {
|
||||
tracing::warn!(error=?err, device_id=%reading.device_id, source=%reading.source, "cannot write energy metric to InfluxDB");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn sample_home_assistant_energy_target(state: &AppState, target_id: &str, entity_id: &str) -> Result<(), AppError> {
|
||||
async fn sample_home_assistant_energy_target(
|
||||
state: &AppState,
|
||||
target_id: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let settings = state.settings.read().await.home_assistant.clone();
|
||||
let payload = home_assistant::read_entity(&state.http, &settings, Some(entity_id))
|
||||
.await
|
||||
.map_err(|err| AppError::Dependency(err.to_string()))?;
|
||||
let state_value = payload.get("state").and_then(Value::as_str).unwrap_or_default();
|
||||
if matches!(state_value, "" | "unknown" | "unavailable") { return Ok(()); }
|
||||
let raw_value: f64 = state_value.parse().map_err(|_| AppError::Dependency("Home Assistant energy state is not numeric".into()))?;
|
||||
let attrs = payload.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||
let device_class = attrs.get("device_class").and_then(Value::as_str).unwrap_or_default();
|
||||
let state_class = attrs.get("state_class").and_then(Value::as_str).unwrap_or_default();
|
||||
let unit = attrs.get("unit_of_measurement").and_then(Value::as_str).unwrap_or_default();
|
||||
let state_value = payload
|
||||
.get("state")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if matches!(state_value, "" | "unknown" | "unavailable") {
|
||||
return Ok(());
|
||||
}
|
||||
let raw_value: f64 = state_value
|
||||
.parse()
|
||||
.map_err(|_| AppError::Dependency("Home Assistant energy state is not numeric".into()))?;
|
||||
let attrs = payload
|
||||
.get("attributes")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let device_class = attrs
|
||||
.get("device_class")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let state_class = attrs
|
||||
.get("state_class")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let unit = attrs
|
||||
.get("unit_of_measurement")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if device_class != "energy" || !matches!(state_class, "total" | "total_increasing") {
|
||||
return Err(AppError::BadRequest("selected Home Assistant entity is not a cumulative energy sensor".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"selected Home Assistant entity is not a cumulative energy sensor".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") {
|
||||
return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must use Wh or kWh".into(),
|
||||
));
|
||||
}
|
||||
let _ = record_cumulative_energy_sample(state, target_id, "home_assistant", raw_value, unit, None)?;
|
||||
let _ =
|
||||
record_cumulative_energy_sample(state, target_id, "home_assistant", raw_value, unit, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sample_home_assistant_energy_device(state: &AppState, device: &Device) -> Result<(), AppError> {
|
||||
let Some(entity_id) = device.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { return Ok(()); };
|
||||
async fn sample_home_assistant_energy_device(
|
||||
state: &AppState,
|
||||
device: &Device,
|
||||
) -> Result<(), AppError> {
|
||||
let Some(entity_id) = device
|
||||
.ha_energy_entity_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
sample_home_assistant_energy_target(state, &device.id, entity_id).await
|
||||
}
|
||||
|
||||
@@ -113,17 +160,33 @@ pub(crate) async fn home_assistant_energy_loop(state: AppState) {
|
||||
let settings = state.settings.read().await.home_assistant.clone();
|
||||
if !settings.url.trim().is_empty() && !settings.token.trim().is_empty() {
|
||||
if let Ok(devices) = state.db.list_devices() {
|
||||
for device in devices.into_iter().filter(|device| device.enabled && device.ha_energy_entity_id.is_some()) {
|
||||
for device in devices
|
||||
.into_iter()
|
||||
.filter(|device| device.enabled && device.ha_energy_entity_id.is_some())
|
||||
{
|
||||
if let Err(err) = sample_home_assistant_energy_device(&state, &device).await {
|
||||
tracing::warn!(device=%device.id, error=?err, "Home Assistant energy sample failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(groups) = state.db.list_device_groups() {
|
||||
for group in groups.into_iter().filter(|group| matches!(group.energy_source, EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto)) {
|
||||
let Some(entity_id) = group.ha_energy_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; };
|
||||
for group in groups.into_iter().filter(|group| {
|
||||
matches!(
|
||||
group.energy_source,
|
||||
EnergySourcePreference::HomeAssistant | EnergySourcePreference::Auto
|
||||
)
|
||||
}) {
|
||||
let Some(entity_id) = group
|
||||
.ha_energy_entity_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let target_id = format!("group:{}", group.id);
|
||||
if let Err(err) = sample_home_assistant_energy_target(&state, &target_id, entity_id).await {
|
||||
if let Err(err) =
|
||||
sample_home_assistant_energy_target(&state, &target_id, entity_id).await
|
||||
{
|
||||
tracing::warn!(group=%group.id, error=?err, "Home Assistant installation energy sample failed");
|
||||
}
|
||||
}
|
||||
@@ -145,14 +208,26 @@ mod energy_tests {
|
||||
|
||||
#[test]
|
||||
fn cumulative_counter_becomes_non_negative_delta() {
|
||||
assert_eq!(cumulative_energy_delta(None, 152.1), (0.0, "baseline", false));
|
||||
assert_eq!(
|
||||
cumulative_energy_delta(None, 152.1),
|
||||
(0.0, "baseline", false)
|
||||
);
|
||||
let (delta, quality, reset) = cumulative_energy_delta(Some(152.1), 152.4);
|
||||
assert!((delta - 0.3).abs() < 1e-9);
|
||||
assert_eq!(quality, "ok");
|
||||
assert!(!reset);
|
||||
assert_eq!(cumulative_energy_delta(Some(152.4), 152.4), (0.0, "duplicate", false));
|
||||
assert_eq!(cumulative_energy_delta(Some(153.0), 1.0), (0.0, "reset", true));
|
||||
assert_eq!(cumulative_energy_delta(Some(1.0), 150.0), (0.0, "anomaly_large_jump", true));
|
||||
assert_eq!(
|
||||
cumulative_energy_delta(Some(152.4), 152.4),
|
||||
(0.0, "duplicate", false)
|
||||
);
|
||||
assert_eq!(
|
||||
cumulative_energy_delta(Some(153.0), 1.0),
|
||||
(0.0, "reset", true)
|
||||
);
|
||||
assert_eq!(
|
||||
cumulative_energy_delta(Some(1.0), 150.0),
|
||||
(0.0, "anomaly_large_jump", true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
fn installation_outdoor_temperature(state: &AppState, device_id: &str) -> Option<f64> {
|
||||
let group = state.db.device_group_for_device(device_id).ok().flatten()?;
|
||||
let source_id = group.outdoor_temperature_device_id.as_deref()?;
|
||||
state.db.get_device(source_id).ok().flatten()?.outdoor_temperature
|
||||
state
|
||||
.db
|
||||
.get_device(source_id)
|
||||
.ok()
|
||||
.flatten()?
|
||||
.outdoor_temperature
|
||||
}
|
||||
|
||||
fn record_zone_history(
|
||||
|
||||
@@ -142,7 +142,8 @@ fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool {
|
||||
"mode" => device.mode == baseline.mode,
|
||||
"target_temperature" => {
|
||||
let step = device.capabilities.temperature_step.max(0.5);
|
||||
(device.target_temperature / step).round() == (baseline.target_temperature / step).round()
|
||||
(device.target_temperature / step).round()
|
||||
== (baseline.target_temperature / step).round()
|
||||
}
|
||||
"fan_speed" => device.fan_speed == baseline.fan_speed,
|
||||
"quiet" => device.quiet == baseline.quiet,
|
||||
|
||||
+246
-80
@@ -10,8 +10,14 @@ fn device_runtime_change_affects_control_plan(before: &Device, after: &Device) -
|
||||
|| before.communication_failures != after.communication_failures
|
||||
}
|
||||
|
||||
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
|
||||
async fn lock_poll_zone_operations(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
|
||||
let mut zone_ids: Vec<String> = state
|
||||
.db
|
||||
.list_zones()?
|
||||
.into_iter()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
@@ -35,7 +41,9 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppEr
|
||||
|
||||
// Caller must hold the device lock and every current zone lock associated with this device.
|
||||
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
let mut device = state
|
||||
.db
|
||||
.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
let before = device.clone();
|
||||
poll_device(state, &mut device).await;
|
||||
@@ -68,13 +76,19 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
||||
|
||||
// LAN always runs first and keeps its historical sequential locking/transport behavior.
|
||||
// Cloud work is detached afterwards so an Internet/broker timeout cannot delay UDP cycles.
|
||||
for device in devices.iter().filter(|device| device.enabled && device.connection_type == ConnectionType::Local) {
|
||||
for device in devices
|
||||
.iter()
|
||||
.filter(|device| device.enabled && device.connection_type == ConnectionType::Local)
|
||||
{
|
||||
let _zone_guards = lock_poll_zone_operations(state, &device.id).await?;
|
||||
let _device_guard = state.lock_device_operation(&device.id).await;
|
||||
let _ = poll_one_locked(state, &device.id).await?;
|
||||
}
|
||||
|
||||
for device in devices.into_iter().filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud) {
|
||||
for device in devices
|
||||
.into_iter()
|
||||
.filter(|device| device.enabled && device.connection_type == ConnectionType::GreeCloud)
|
||||
{
|
||||
// A failed Cloud read has no last_cloud_sync, so use updated_at (which is refreshed
|
||||
// on failures) as the retry baseline. Otherwise an offline unit would be considered
|
||||
// due on every fast LAN poll cycle and detached tasks would accumulate indefinitely.
|
||||
@@ -84,7 +98,9 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
||||
.signed_duration_since(retry_baseline)
|
||||
.num_seconds()
|
||||
>= cloud_interval as i64;
|
||||
if !due { continue; }
|
||||
if !due {
|
||||
continue;
|
||||
}
|
||||
if startup {
|
||||
// Persist a conservative startup state before the asynchronous Cloud read. This
|
||||
// prevents a stale pre-restart Online snapshot from driving thermostat commands.
|
||||
@@ -115,7 +131,10 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
||||
/// Poll all enabled devices when the caller already holds the corresponding zone and device locks.
|
||||
/// Used by configuration import so no command/poll can interleave with the replacement.
|
||||
pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
|
||||
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
|
||||
let device_ids: Vec<String> = state
|
||||
.db
|
||||
.list_devices()?
|
||||
.into_iter()
|
||||
.filter(|device| device.enabled)
|
||||
.map(|device| device.id)
|
||||
.collect();
|
||||
@@ -137,13 +156,24 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
match state.providers.cloud().poll(&cloud_settings, &all_devices, device).await {
|
||||
match state
|
||||
.providers
|
||||
.cloud()
|
||||
.poll(&cloud_settings, &all_devices, device)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
device.pending_command = false;
|
||||
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
device.response_time_ms =
|
||||
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
device.refresh_capabilities();
|
||||
if previous_failures > 0 {
|
||||
state.log("info", "gree_cloud.device_online", &format!("{} is online through GREE Cloud", device.name), json!({"device_id": device.id}));
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.device_online",
|
||||
&format!("{} is online through GREE Cloud", device.name),
|
||||
json!({"device_id": device.id}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -196,7 +226,8 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
}
|
||||
if device.communication_failures == 0 && device.online {
|
||||
device.connection_status = ConnectionStatus::Online;
|
||||
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
device.response_time_ms =
|
||||
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
}
|
||||
log_poll_health_transition(state, device, previous_failures).await;
|
||||
}
|
||||
@@ -205,13 +236,14 @@ fn register_cloud_poll_failure(device: &mut Device, error: &str) {
|
||||
device.communication_failures = device.communication_failures.saturating_add(1);
|
||||
device.online = false;
|
||||
let lower = error.to_ascii_lowercase();
|
||||
device.connection_status = if lower.contains("authentication") || lower.contains("not authorized") {
|
||||
ConnectionStatus::AuthenticationError
|
||||
} else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") {
|
||||
ConnectionStatus::CloudDisconnected
|
||||
} else {
|
||||
ConnectionStatus::Offline
|
||||
};
|
||||
device.connection_status =
|
||||
if lower.contains("authentication") || lower.contains("not authorized") {
|
||||
ConnectionStatus::AuthenticationError
|
||||
} else if lower.contains("mqtt") || lower.contains("connect") || lower.contains("tls") {
|
||||
ConnectionStatus::CloudDisconnected
|
||||
} else {
|
||||
ConnectionStatus::Offline
|
||||
};
|
||||
device.response_time_ms = None;
|
||||
if device.last_seen.is_none() {
|
||||
device.last_cloud_sync = None;
|
||||
@@ -230,7 +262,13 @@ fn cloud_poll_public_error(error: &str) -> String {
|
||||
}
|
||||
|
||||
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) {
|
||||
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
|
||||
let threshold = state
|
||||
.settings
|
||||
.read()
|
||||
.await
|
||||
.notifications
|
||||
.communication_failure_threshold
|
||||
.max(2);
|
||||
let current_failures = u32::from(device.communication_failures);
|
||||
let previous_failures = u32::from(previous_failures);
|
||||
if current_failures >= threshold && previous_failures < threshold {
|
||||
@@ -238,23 +276,33 @@ async fn log_poll_health_transition(state: &AppState, device: &Device, previous_
|
||||
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
|
||||
}));
|
||||
} else if current_failures == 0 && previous_failures >= threshold {
|
||||
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id}));
|
||||
state.log(
|
||||
"info",
|
||||
"device.recovered",
|
||||
&format!("{} is responding again", device.name),
|
||||
json!({"device_id": device.id}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn simulate_tick(device: &mut Device) {
|
||||
let mut current = device.current_temperature.unwrap_or(25.0);
|
||||
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
|
||||
let minute_wave =
|
||||
((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
|
||||
let ambient = 25.5 + minute_wave * 0.35;
|
||||
if device.power {
|
||||
match device.mode.as_str() {
|
||||
"cool" => {
|
||||
let floor = device.target_temperature - 0.2;
|
||||
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; }
|
||||
if current > floor {
|
||||
current -= if device.turbo { 0.25 } else { 0.12 };
|
||||
}
|
||||
}
|
||||
"heat" => {
|
||||
let ceiling = device.target_temperature + 0.2;
|
||||
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; }
|
||||
if current < ceiling {
|
||||
current += if device.turbo { 0.25 } else { 0.12 };
|
||||
}
|
||||
}
|
||||
"dry" => current -= 0.04,
|
||||
_ => current += (ambient - current) * 0.02,
|
||||
@@ -265,7 +313,9 @@ fn simulate_tick(device: &mut Device) {
|
||||
device.current_temperature = Some((current * 10.0).round() / 10.0);
|
||||
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
|
||||
// Correct rounding for outdoor temperature without accumulating precision noise.
|
||||
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
|
||||
device.outdoor_temperature = device
|
||||
.outdoor_temperature
|
||||
.map(|v| (v * 10.0).round() / 10.0);
|
||||
device.online = true;
|
||||
device.response_time_ms = Some(0);
|
||||
device.last_seen = Some(Utc::now());
|
||||
@@ -279,10 +329,15 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
device_id: device.id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
indoor_temperature: device.current_temperature,
|
||||
outdoor_temperature: installation_outdoor_temperature(state, &device.id).or(device.outdoor_temperature),
|
||||
outdoor_temperature: installation_outdoor_temperature(state, &device.id)
|
||||
.or(device.outdoor_temperature),
|
||||
target_temperature: device.target_temperature,
|
||||
power: device.power,
|
||||
source: if device.simulated { "simulator".into() } else { "gree".into() },
|
||||
source: if device.simulated {
|
||||
"simulator".into()
|
||||
} else {
|
||||
"gree".into()
|
||||
},
|
||||
};
|
||||
state.db.add_reading(&reading)?;
|
||||
queue_influx_device(state, reading);
|
||||
@@ -292,30 +347,50 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
fn record_poll_failure(device: &mut Device, error: &str) {
|
||||
device.communication_failures = device.communication_failures.saturating_add(1);
|
||||
// A single dropped UDP response is not enough to declare an AC offline.
|
||||
if device.communication_failures >= 3 { device.online = false; device.connection_status = ConnectionStatus::Offline; }
|
||||
if device.communication_failures >= 3 {
|
||||
device.online = false;
|
||||
device.connection_status = ConnectionStatus::Offline;
|
||||
}
|
||||
device.last_error = Some(error.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
|
||||
fn register_device_failure(
|
||||
state: &AppState,
|
||||
device: &mut Device,
|
||||
error: &str,
|
||||
) -> Result<(), AppError> {
|
||||
record_poll_failure(device, error);
|
||||
state.db.save_device(device)?;
|
||||
// Command failures change live communication health; publish the updated snapshot immediately.
|
||||
state.broadcast("device.updated", serde_json::to_value(&*device)?);
|
||||
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"consecutive_failures": device.communication_failures,
|
||||
"offline": !device.online,
|
||||
}));
|
||||
state.log(
|
||||
"warn",
|
||||
"device.communication_error",
|
||||
&format!("{}: {error}", device.name),
|
||||
json!({
|
||||
"device_id": device.id,
|
||||
"consecutive_failures": device.communication_failures,
|
||||
"offline": !device.online,
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
if let Some(value) = command.target_temperature {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
|
||||
if !(8.0..=30.0).contains(&value) {
|
||||
return Err(AppError::BadRequest(
|
||||
"target temperature must be between 8 and 30 C".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(value) = command.fan_speed {
|
||||
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }
|
||||
if value > 5 {
|
||||
return Err(AppError::BadRequest(
|
||||
"fan speed must be between 0 and 5".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(value) = &command.mode {
|
||||
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
|
||||
@@ -331,12 +406,24 @@ fn poll_completed_successfully(device: &Device) -> bool {
|
||||
|
||||
fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
|
||||
let mut fields = Vec::new();
|
||||
if command.power.is_some() { fields.push("power".to_string()); }
|
||||
if command.mode.is_some() { fields.push("mode".to_string()); }
|
||||
if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); }
|
||||
if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); }
|
||||
if command.quiet.is_some() { fields.push("quiet".to_string()); }
|
||||
if command.sleep.is_some() { fields.push("sleep".to_string()); }
|
||||
if command.power.is_some() {
|
||||
fields.push("power".to_string());
|
||||
}
|
||||
if command.mode.is_some() {
|
||||
fields.push("mode".to_string());
|
||||
}
|
||||
if command.target_temperature.is_some() {
|
||||
fields.push("target_temperature".to_string());
|
||||
}
|
||||
if command.fan_speed.is_some() {
|
||||
fields.push("fan_speed".to_string());
|
||||
}
|
||||
if command.quiet.is_some() {
|
||||
fields.push("quiet".to_string());
|
||||
}
|
||||
if command.sleep.is_some() {
|
||||
fields.push("sleep".to_string());
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
@@ -344,7 +431,9 @@ fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> Dev
|
||||
DeviceCommand {
|
||||
power: command.power.map(|_| device.power),
|
||||
mode: command.mode.as_ref().map(|_| device.mode.clone()),
|
||||
target_temperature: command.target_temperature.map(|_| device.target_temperature),
|
||||
target_temperature: command
|
||||
.target_temperature
|
||||
.map(|_| device.target_temperature),
|
||||
fan_speed: command.fan_speed.map(|_| device.fan_speed),
|
||||
quiet: command.quiet.map(|_| device.quiet),
|
||||
sleep: command.sleep.map(|_| device.sleep),
|
||||
@@ -352,7 +441,12 @@ fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> Dev
|
||||
}
|
||||
}
|
||||
|
||||
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) {
|
||||
async fn remember_controller_command(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: &DeviceCommand,
|
||||
baseline_device: &Device,
|
||||
) {
|
||||
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
|
||||
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
|
||||
let mut pending = state.pending_controller_commands.lock().await;
|
||||
@@ -363,32 +457,64 @@ async fn remember_controller_command(state: &AppState, device_id: &str, command:
|
||||
existing.baselines.push(baseline);
|
||||
// The history only spans one settling window; cap it defensively so a noisy device
|
||||
// cannot grow this allocation without bound.
|
||||
if existing.commands.len() > 8 { existing.commands.remove(0); }
|
||||
if existing.baselines.len() > 8 { existing.baselines.remove(0); }
|
||||
if existing.commands.len() > 8 {
|
||||
existing.commands.remove(0);
|
||||
}
|
||||
if existing.baselines.len() > 8 {
|
||||
existing.baselines.remove(0);
|
||||
}
|
||||
existing.expires_at = expires_at;
|
||||
} else {
|
||||
pending.insert(device_id.to_string(), PendingControllerCommand {
|
||||
commands: vec![command.clone()],
|
||||
baselines: vec![baseline],
|
||||
expires_at,
|
||||
});
|
||||
pending.insert(
|
||||
device_id.to_string(),
|
||||
PendingControllerCommand {
|
||||
commands: vec![command.clone()],
|
||||
baselines: vec![baseline],
|
||||
expires_at,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
|
||||
match field {
|
||||
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
|
||||
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false),
|
||||
"target_temperature" => command.target_temperature
|
||||
"power" => command
|
||||
.power
|
||||
.map(|value| value == device.power)
|
||||
.unwrap_or(false),
|
||||
"mode" => command
|
||||
.mode
|
||||
.as_deref()
|
||||
.map(|value| value == device.mode.as_str())
|
||||
.unwrap_or(false),
|
||||
"target_temperature" => command
|
||||
.target_temperature
|
||||
.map(|value| {
|
||||
let step = device.capabilities.temperature_step.max(0.5);
|
||||
(value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round()
|
||||
== (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round()
|
||||
(value.clamp(
|
||||
device.capabilities.min_temperature,
|
||||
device.capabilities.max_temperature,
|
||||
) / step)
|
||||
.round()
|
||||
== (device.target_temperature.clamp(
|
||||
device.capabilities.min_temperature,
|
||||
device.capabilities.max_temperature,
|
||||
) / step)
|
||||
.round()
|
||||
})
|
||||
.unwrap_or(false),
|
||||
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false),
|
||||
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false),
|
||||
"sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false),
|
||||
"fan_speed" => command
|
||||
.fan_speed
|
||||
.map(|value| value.min(5) == device.fan_speed)
|
||||
.unwrap_or(false),
|
||||
"quiet" => command
|
||||
.quiet
|
||||
.map(|value| value == device.quiet)
|
||||
.unwrap_or(false),
|
||||
"sleep" => command
|
||||
.sleep
|
||||
.map(|value| value == device.sleep)
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -418,7 +544,10 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V
|
||||
};
|
||||
let now = Instant::now();
|
||||
if now > expected.expires_at {
|
||||
let expired_by_ms = now.saturating_duration_since(expected.expires_at).as_millis().min(u64::MAX as u128) as u64;
|
||||
let expired_by_ms = now
|
||||
.saturating_duration_since(expected.expires_at)
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64;
|
||||
return json!({
|
||||
"active": false,
|
||||
"reason": "expired",
|
||||
@@ -427,7 +556,11 @@ async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> V
|
||||
"baselines": expected.baselines,
|
||||
});
|
||||
}
|
||||
let remaining_ms = expected.expires_at.saturating_duration_since(now).as_millis().min(u64::MAX as u128) as u64;
|
||||
let remaining_ms = expected
|
||||
.expires_at
|
||||
.saturating_duration_since(now)
|
||||
.as_millis()
|
||||
.min(u64::MAX as u128) as u64;
|
||||
json!({
|
||||
"active": true,
|
||||
"remaining_ms": remaining_ms,
|
||||
@@ -441,19 +574,27 @@ async fn suppress_expected_controller_changes(
|
||||
device: &Device,
|
||||
fields: Vec<String>,
|
||||
) -> Vec<String> {
|
||||
if fields.is_empty() { return fields; }
|
||||
if fields.is_empty() {
|
||||
return fields;
|
||||
}
|
||||
let mut pending = state.pending_controller_commands.lock().await;
|
||||
let expired = pending.get(&device.id)
|
||||
let expired = pending
|
||||
.get(&device.id)
|
||||
.map(|expected| Instant::now() > expected.expires_at)
|
||||
.unwrap_or(false);
|
||||
if expired {
|
||||
pending.remove(&device.id);
|
||||
return fields;
|
||||
}
|
||||
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
|
||||
let filtered = fields.into_iter()
|
||||
let Some(expected) = pending.get(&device.id).cloned() else {
|
||||
return fields;
|
||||
};
|
||||
let filtered = fields
|
||||
.into_iter()
|
||||
.filter(|field| {
|
||||
let matches_recent_controller_state = expected.commands.iter()
|
||||
let matches_recent_controller_state = expected
|
||||
.commands
|
||||
.iter()
|
||||
.chain(expected.baselines.iter())
|
||||
.any(|command| command_field_matches_device(command, field, device));
|
||||
!matches_recent_controller_state
|
||||
@@ -467,8 +608,12 @@ async fn suppress_expected_controller_changes(
|
||||
|
||||
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
|
||||
let mut fields = Vec::new();
|
||||
if before.power != after.power { fields.push("power".to_string()); }
|
||||
if before.mode != after.mode { fields.push("mode".to_string()); }
|
||||
if before.power != after.power {
|
||||
fields.push("power".to_string());
|
||||
}
|
||||
if before.mode != after.mode {
|
||||
fields.push("mode".to_string());
|
||||
}
|
||||
let temperature_step = after.capabilities.temperature_step.max(0.5);
|
||||
if (before.target_temperature / temperature_step).round()
|
||||
!= (after.target_temperature / temperature_step).round()
|
||||
@@ -478,15 +623,14 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
|
||||
// Some GREE units accept the controller's standby Low fan hint and later report Auto
|
||||
// again without user interaction. Treat that one known normalization as firmware drift,
|
||||
// not as a remote-control takeover. Other fan changes remain meaningful manual input.
|
||||
let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
|
||||
let standby_low_to_auto =
|
||||
zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
|
||||
if before.fan_speed != after.fan_speed && !standby_low_to_auto {
|
||||
fields.push("fan_speed".to_string());
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
let mut receiver = state.providers.cloud().subscribe_push();
|
||||
loop {
|
||||
@@ -502,27 +646,43 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
let matching: Vec<String> = devices
|
||||
.into_iter()
|
||||
.filter(|device| {
|
||||
if device.connection_type != ConnectionType::GreeCloud { return false; }
|
||||
if let Some(id) = event.cloud_device_id.as_deref() {
|
||||
return device.cloud_device_id.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(id));
|
||||
if device.connection_type != ConnectionType::GreeCloud {
|
||||
return false;
|
||||
}
|
||||
device.cloud_parent_mac.as_deref().is_some_and(|value| value.eq_ignore_ascii_case(&event.parent_mac))
|
||||
if let Some(id) = event.cloud_device_id.as_deref() {
|
||||
return device
|
||||
.cloud_device_id
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(id));
|
||||
}
|
||||
device
|
||||
.cloud_parent_mac
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(&event.parent_mac))
|
||||
})
|
||||
.map(|device| device.id)
|
||||
.collect();
|
||||
for device_id in matching {
|
||||
let _guard = state.lock_device_operation(&device_id).await;
|
||||
let Ok(Some(mut device)) = state.db.get_device(&device_id) else { continue; };
|
||||
let Ok(Some(mut device)) = state.db.get_device(&device_id) else {
|
||||
continue;
|
||||
};
|
||||
let before = device.clone();
|
||||
if !event.properties.is_empty() {
|
||||
if let Some(raw_energy) = event.properties.get("ElcAll").and_then(|value| {
|
||||
value.as_f64()
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_i64().map(|v| v as f64))
|
||||
.or_else(|| value.as_u64().map(|v| v as f64))
|
||||
.or_else(|| value.as_str().and_then(|v| v.parse::<f64>().ok()))
|
||||
}) {
|
||||
if let Err(err) = record_cumulative_energy_sample(
|
||||
&state, &device.id, "gree_cloud", raw_energy, "0.1kWh", None
|
||||
&state,
|
||||
&device.id,
|
||||
"gree_cloud",
|
||||
raw_energy,
|
||||
"0.1kWh",
|
||||
None,
|
||||
) {
|
||||
tracing::warn!(device=%device.id, error=?err, "cannot record GREE Cloud energy sample");
|
||||
}
|
||||
@@ -535,7 +695,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
device.online = true;
|
||||
device.communication_failures = 0;
|
||||
device.last_error = None;
|
||||
if let Some(version) = event.cipher_version { device.protocol_version = version; }
|
||||
if let Some(version) = event.cipher_version {
|
||||
device.protocol_version = version;
|
||||
}
|
||||
device.refresh_capabilities();
|
||||
} else if event.connected == Some(true) {
|
||||
// A connect topic proves cloud presence but does not replace a status frame.
|
||||
@@ -550,7 +712,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
}
|
||||
if !event.properties.is_empty() {
|
||||
let _ = record_reading(&state, &device);
|
||||
if let Err(err) = detect_external_device_control(&state, &before, &device).await {
|
||||
if let Err(err) =
|
||||
detect_external_device_control(&state, &before, &device).await
|
||||
{
|
||||
tracing::warn!(device=%device_id, error=?err, "cannot process external GREE Cloud state change");
|
||||
}
|
||||
}
|
||||
@@ -561,7 +725,9 @@ pub(crate) async fn cloud_push_loop(state: AppState) {
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!(skipped, "GREE Cloud push state receiver lagged"),
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(skipped, "GREE Cloud push state receiver lagged")
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +148,13 @@ pub fn start(state: AppState) {
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot compact history"),
|
||||
}
|
||||
match maintenance_state.db.compact_network_history(compaction_days) {
|
||||
Ok(count) if count > 0 => tracing::info!(count, "connectivity history samples compacted"),
|
||||
match maintenance_state
|
||||
.db
|
||||
.compact_network_history(compaction_days)
|
||||
{
|
||||
Ok(count) if count > 0 => {
|
||||
tracing::info!(count, "connectivity history samples compacted")
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot compact connectivity history"),
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ pub fn next_schedule_boundary_utc(
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let minute_of_day = candidate.hour() * 60 + candidate.minute();
|
||||
let previous_local = base + chrono::Duration::minutes(minute - 1);
|
||||
let clock_discontinuity = candidate.naive_local() - previous_local.naive_local()
|
||||
!= chrono::Duration::minutes(1);
|
||||
let clock_discontinuity =
|
||||
candidate.naive_local() - previous_local.naive_local() != chrono::Duration::minutes(1);
|
||||
if !boundary_minutes.contains(&minute_of_day) && !clock_discontinuity {
|
||||
continue;
|
||||
}
|
||||
|
||||
+617
-133
File diff suppressed because it is too large
Load Diff
@@ -77,7 +77,8 @@ async fn resolve_cycle_outdoor_temperature(
|
||||
None
|
||||
};
|
||||
let device_groups = state.db.list_device_groups().unwrap_or_default();
|
||||
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices, &device_groups));
|
||||
let temperature =
|
||||
from_home_assistant.or_else(|| gree_outdoor_temperature(devices, &device_groups));
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != temperature {
|
||||
*current = temperature;
|
||||
@@ -237,14 +238,9 @@ fn refresh_zone_temperature(
|
||||
} else {
|
||||
"ha.sensor_error"
|
||||
};
|
||||
let entity_id = resolved_entity
|
||||
.as_deref()
|
||||
.or(zone.ha_entity_id.as_deref());
|
||||
let message = home_assistant_sensor_log_message(
|
||||
&err,
|
||||
entity_id,
|
||||
&zone.name,
|
||||
);
|
||||
let entity_id = resolved_entity.as_deref().or(zone.ha_entity_id.as_deref());
|
||||
let message =
|
||||
home_assistant_sensor_log_message(&err, entity_id, &zone.name);
|
||||
state.log(
|
||||
"warn",
|
||||
kind,
|
||||
@@ -1196,10 +1192,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
outdoor_temperature,
|
||||
settings.poll_interval_seconds,
|
||||
);
|
||||
publish_persisted_zone_cycle(
|
||||
state,
|
||||
persist_zone_cycle(state, &zone, cycle_started_at)?,
|
||||
)?;
|
||||
publish_persisted_zone_cycle(state, persist_zone_cycle(state, &zone, cycle_started_at)?)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -129,7 +129,9 @@ pub async fn test_connection(
|
||||
settings: &HomeAssistantSettings,
|
||||
) -> Result<Option<Value>> {
|
||||
let (mut base, token) = connection(settings)?;
|
||||
base = base.join("api/").context("cannot build Home Assistant API URL")?;
|
||||
base = base
|
||||
.join("api/")
|
||||
.context("cannot build Home Assistant API URL")?;
|
||||
let response = request_client(default_client, settings)?
|
||||
.get(base)
|
||||
.bearer_auth(token.trim())
|
||||
@@ -323,7 +325,9 @@ pub async fn list_entities(
|
||||
settings: &HomeAssistantSettings,
|
||||
) -> Result<Vec<Value>> {
|
||||
let (mut base, token) = connection(settings)?;
|
||||
base = base.join("api/states").context("cannot build Home Assistant API URL")?;
|
||||
base = base
|
||||
.join("api/states")
|
||||
.context("cannot build Home Assistant API URL")?;
|
||||
let response = request_client(default_client, settings)?
|
||||
.get(base)
|
||||
.bearer_auth(token.trim())
|
||||
|
||||
+17
-1
@@ -4,7 +4,9 @@ use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::models::{EnergyReading, HaReading, InfluxDbSettings, NetworkReading, Reading, ZoneReading};
|
||||
use crate::models::{
|
||||
EnergyReading, HaReading, InfluxDbSettings, NetworkReading, Reading, ZoneReading,
|
||||
};
|
||||
|
||||
const DEVICE_MEASUREMENT: &str = "gree_device";
|
||||
const ZONE_MEASUREMENT: &str = "gree_zone";
|
||||
@@ -16,3 +18,17 @@ const NETWORK_MEASUREMENT: &str = "gree_network";
|
||||
include!("influxdb/write.rs");
|
||||
include!("influxdb/query.rs");
|
||||
include!("influxdb/codec.rs");
|
||||
|
||||
pub async fn test_connection(client: &Client, settings: &InfluxDbSettings) -> Result<()> {
|
||||
validate(settings)?;
|
||||
if settings.version == "1" {
|
||||
query_v1(client, settings, "SHOW MEASUREMENTS LIMIT 1").await?;
|
||||
} else {
|
||||
let query = format!(
|
||||
"from(bucket: {}) |> range(start: -1m) |> limit(n: 1)",
|
||||
flux_string(&settings.bucket)
|
||||
);
|
||||
query_v2(client, settings, &query).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+52
-14
@@ -489,7 +489,9 @@ pub async fn query_energy(
|
||||
for item in series {
|
||||
let source = item.tags.get("source").cloned().unwrap_or_default();
|
||||
for row in item.rows {
|
||||
let Some(timestamp) = row_time(&row) else { continue; };
|
||||
let Some(timestamp) = row_time(&row) else {
|
||||
continue;
|
||||
};
|
||||
out.push(EnergyReading {
|
||||
id: 0,
|
||||
device_id: device_id.to_string(),
|
||||
@@ -526,7 +528,9 @@ pub async fn query_energy(
|
||||
let rows = query_v2(client, settings, &query).await?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows.into_iter().take(limit as usize) {
|
||||
let Some(timestamp) = parse_flux_time(&row) else { continue; };
|
||||
let Some(timestamp) = parse_flux_time(&row) else {
|
||||
continue;
|
||||
};
|
||||
out.push(EnergyReading {
|
||||
id: 0,
|
||||
device_id: device_id.to_string(),
|
||||
@@ -566,10 +570,16 @@ pub async fn query_network(
|
||||
let mut out = Vec::new();
|
||||
for item in series {
|
||||
let id = item.tags.get("target_id").cloned().unwrap_or_default();
|
||||
let kind = item.tags.get("target_kind").cloned().unwrap_or_else(|| "device".into());
|
||||
let kind = item
|
||||
.tags
|
||||
.get("target_kind")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "device".into());
|
||||
let source = item.tags.get("source").cloned().unwrap_or_default();
|
||||
for row in item.rows {
|
||||
let Some(timestamp) = row_time(&row) else { continue; };
|
||||
let Some(timestamp) = row_time(&row) else {
|
||||
continue;
|
||||
};
|
||||
out.push(NetworkReading {
|
||||
id: 0,
|
||||
target_id: id.clone(),
|
||||
@@ -577,9 +587,17 @@ pub async fn query_network(
|
||||
timestamp,
|
||||
latency_ms: row_num(&row, "latency_ms"),
|
||||
jitter_ms: row_num(&row, "jitter_ms"),
|
||||
packet_loss_pct: row_num(&row, "packet_loss_pct").unwrap_or(0.0).clamp(0.0, 100.0),
|
||||
sample_count: row_num(&row, "sample_count").unwrap_or(0.0).round().max(0.0) as u32,
|
||||
successful_samples: row_num(&row, "successful_samples").unwrap_or(0.0).round().max(0.0) as u32,
|
||||
packet_loss_pct: row_num(&row, "packet_loss_pct")
|
||||
.unwrap_or(0.0)
|
||||
.clamp(0.0, 100.0),
|
||||
sample_count: row_num(&row, "sample_count")
|
||||
.unwrap_or(0.0)
|
||||
.round()
|
||||
.max(0.0) as u32,
|
||||
successful_samples: row_num(&row, "successful_samples")
|
||||
.unwrap_or(0.0)
|
||||
.round()
|
||||
.max(0.0) as u32,
|
||||
source: source.clone(),
|
||||
});
|
||||
}
|
||||
@@ -590,7 +608,12 @@ pub async fn query_network(
|
||||
}
|
||||
|
||||
let tags = target_id
|
||||
.map(|value| format!(" |> filter(fn: (r) => r.target_id == {})", flux_string(value)))
|
||||
.map(|value| {
|
||||
format!(
|
||||
" |> filter(fn: (r) => r.target_id == {})",
|
||||
flux_string(value)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let query = flux_query(
|
||||
settings,
|
||||
@@ -604,18 +627,33 @@ pub async fn query_network(
|
||||
let rows = query_v2(client, settings, &query).await?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows.into_iter().take(limit as usize) {
|
||||
let Some(timestamp) = parse_flux_time(&row) else { continue; };
|
||||
let Some(id) = row.get("target_id").filter(|v| !v.is_empty()) else { continue; };
|
||||
let Some(timestamp) = parse_flux_time(&row) else {
|
||||
continue;
|
||||
};
|
||||
let Some(id) = row.get("target_id").filter(|v| !v.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
out.push(NetworkReading {
|
||||
id: 0,
|
||||
target_id: id.clone(),
|
||||
target_kind: row.get("target_kind").cloned().unwrap_or_else(|| "device".into()),
|
||||
target_kind: row
|
||||
.get("target_kind")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "device".into()),
|
||||
timestamp,
|
||||
latency_ms: row_f64(&row, "latency_ms"),
|
||||
jitter_ms: row_f64(&row, "jitter_ms"),
|
||||
packet_loss_pct: row_f64(&row, "packet_loss_pct").unwrap_or(0.0).clamp(0.0, 100.0),
|
||||
sample_count: row_f64(&row, "sample_count").unwrap_or(0.0).round().max(0.0) as u32,
|
||||
successful_samples: row_f64(&row, "successful_samples").unwrap_or(0.0).round().max(0.0) as u32,
|
||||
packet_loss_pct: row_f64(&row, "packet_loss_pct")
|
||||
.unwrap_or(0.0)
|
||||
.clamp(0.0, 100.0),
|
||||
sample_count: row_f64(&row, "sample_count")
|
||||
.unwrap_or(0.0)
|
||||
.round()
|
||||
.max(0.0) as u32,
|
||||
successful_samples: row_f64(&row, "successful_samples")
|
||||
.unwrap_or(0.0)
|
||||
.round()
|
||||
.max(0.0) as u32,
|
||||
source: row.get("source").cloned().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
+84
-20
@@ -1,19 +1,32 @@
|
||||
|
||||
pub async fn write_network(
|
||||
client: &Client,
|
||||
settings: &InfluxDbSettings,
|
||||
reading: &NetworkReading,
|
||||
) -> Result<()> {
|
||||
if !settings.enabled { return Ok(()); }
|
||||
if !settings.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "latency_ms", reading.latency_ms);
|
||||
push_float(&mut fields, "jitter_ms", reading.jitter_ms);
|
||||
push_float(&mut fields, "packet_loss_pct", Some(reading.packet_loss_pct.clamp(0.0, 100.0)));
|
||||
push_float(
|
||||
&mut fields,
|
||||
"packet_loss_pct",
|
||||
Some(reading.packet_loss_pct.clamp(0.0, 100.0)),
|
||||
);
|
||||
push_int(&mut fields, "sample_count", reading.sample_count as i64);
|
||||
push_int(&mut fields, "successful_samples", reading.successful_samples as i64);
|
||||
push_int(
|
||||
&mut fields,
|
||||
"successful_samples",
|
||||
reading.successful_samples as i64,
|
||||
);
|
||||
let line = line_protocol(
|
||||
NETWORK_MEASUREMENT,
|
||||
&[("target_id", &reading.target_id), ("target_kind", &reading.target_kind), ("source", &reading.source)],
|
||||
&[
|
||||
("target_id", &reading.target_id),
|
||||
("target_kind", &reading.target_kind),
|
||||
("source", &reading.source),
|
||||
],
|
||||
fields,
|
||||
reading.timestamp,
|
||||
)?;
|
||||
@@ -25,18 +38,32 @@ pub async fn write_network_batch(
|
||||
settings: &InfluxDbSettings,
|
||||
readings: &[NetworkReading],
|
||||
) -> Result<()> {
|
||||
if !settings.enabled || readings.is_empty() { return Ok(()); }
|
||||
if !settings.enabled || readings.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut lines = Vec::with_capacity(readings.len());
|
||||
for reading in readings {
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "latency_ms", reading.latency_ms);
|
||||
push_float(&mut fields, "jitter_ms", reading.jitter_ms);
|
||||
push_float(&mut fields, "packet_loss_pct", Some(reading.packet_loss_pct.clamp(0.0, 100.0)));
|
||||
push_float(
|
||||
&mut fields,
|
||||
"packet_loss_pct",
|
||||
Some(reading.packet_loss_pct.clamp(0.0, 100.0)),
|
||||
);
|
||||
push_int(&mut fields, "sample_count", reading.sample_count as i64);
|
||||
push_int(&mut fields, "successful_samples", reading.successful_samples as i64);
|
||||
push_int(
|
||||
&mut fields,
|
||||
"successful_samples",
|
||||
reading.successful_samples as i64,
|
||||
);
|
||||
lines.push(line_protocol(
|
||||
NETWORK_MEASUREMENT,
|
||||
&[("target_id", &reading.target_id), ("target_kind", &reading.target_kind), ("source", &reading.source)],
|
||||
&[
|
||||
("target_id", &reading.target_id),
|
||||
("target_kind", &reading.target_kind),
|
||||
("source", &reading.source),
|
||||
],
|
||||
fields,
|
||||
reading.timestamp,
|
||||
)?);
|
||||
@@ -44,22 +71,40 @@ pub async fn write_network_batch(
|
||||
write_lines(client, settings, lines.join("\n")).await
|
||||
}
|
||||
|
||||
|
||||
pub async fn write_energy(
|
||||
client: &Client,
|
||||
settings: &InfluxDbSettings,
|
||||
reading: &EnergyReading,
|
||||
) -> Result<()> {
|
||||
if !settings.enabled { return Ok(()); }
|
||||
if !settings.enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value));
|
||||
push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh));
|
||||
push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0)));
|
||||
push_float(
|
||||
&mut fields,
|
||||
"raw_meter_value",
|
||||
Some(reading.raw_meter_value),
|
||||
);
|
||||
push_float(
|
||||
&mut fields,
|
||||
"normalized_meter_kwh",
|
||||
Some(reading.normalized_meter_kwh),
|
||||
);
|
||||
push_float(
|
||||
&mut fields,
|
||||
"consumption_kwh",
|
||||
Some(reading.consumption_kwh.max(0.0)),
|
||||
);
|
||||
push_float(&mut fields, "current_power_kw", reading.current_power_kw);
|
||||
push_int(&mut fields, "reset_detected", reading.reset_detected as i64);
|
||||
let line = line_protocol(
|
||||
ENERGY_MEASUREMENT,
|
||||
&[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)],
|
||||
&[
|
||||
("device_id", &reading.device_id),
|
||||
("source", &reading.source),
|
||||
("quality", &reading.quality),
|
||||
("raw_unit", &reading.raw_unit),
|
||||
],
|
||||
fields,
|
||||
reading.timestamp,
|
||||
)?;
|
||||
@@ -71,18 +116,37 @@ pub async fn write_energy_batch(
|
||||
settings: &InfluxDbSettings,
|
||||
readings: &[EnergyReading],
|
||||
) -> Result<()> {
|
||||
if !settings.enabled || readings.is_empty() { return Ok(()); }
|
||||
if !settings.enabled || readings.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut lines = Vec::with_capacity(readings.len());
|
||||
for reading in readings {
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "raw_meter_value", Some(reading.raw_meter_value));
|
||||
push_float(&mut fields, "normalized_meter_kwh", Some(reading.normalized_meter_kwh));
|
||||
push_float(&mut fields, "consumption_kwh", Some(reading.consumption_kwh.max(0.0)));
|
||||
push_float(
|
||||
&mut fields,
|
||||
"raw_meter_value",
|
||||
Some(reading.raw_meter_value),
|
||||
);
|
||||
push_float(
|
||||
&mut fields,
|
||||
"normalized_meter_kwh",
|
||||
Some(reading.normalized_meter_kwh),
|
||||
);
|
||||
push_float(
|
||||
&mut fields,
|
||||
"consumption_kwh",
|
||||
Some(reading.consumption_kwh.max(0.0)),
|
||||
);
|
||||
push_float(&mut fields, "current_power_kw", reading.current_power_kw);
|
||||
push_int(&mut fields, "reset_detected", reading.reset_detected as i64);
|
||||
lines.push(line_protocol(
|
||||
ENERGY_MEASUREMENT,
|
||||
&[("device_id", &reading.device_id), ("source", &reading.source), ("quality", &reading.quality), ("raw_unit", &reading.raw_unit)],
|
||||
&[
|
||||
("device_id", &reading.device_id),
|
||||
("source", &reading.source),
|
||||
("quality", &reading.quality),
|
||||
("raw_unit", &reading.raw_unit),
|
||||
],
|
||||
fields,
|
||||
reading.timestamp,
|
||||
)?);
|
||||
|
||||
+8
-3
@@ -49,7 +49,12 @@ async fn main() -> Result<()> {
|
||||
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
|
||||
}
|
||||
config.apply_runtime_env_overrides(&mut runtime_settings);
|
||||
if runtime_settings.gree_cloud.installation_id.trim().is_empty() {
|
||||
if runtime_settings
|
||||
.gree_cloud
|
||||
.installation_id
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
runtime_settings.gree_cloud.installation_id = uuid::Uuid::new_v4().to_string();
|
||||
}
|
||||
if runtime_settings.gree_cloud.account_id.trim().is_empty() {
|
||||
@@ -162,8 +167,8 @@ async fn main() -> Result<()> {
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
|
||||
.into_future();
|
||||
.with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone()))
|
||||
.into_future();
|
||||
tokio::pin!(server);
|
||||
let deadline_rx = shutdown_rx;
|
||||
tokio::select! {
|
||||
|
||||
+70
-25
@@ -17,7 +17,6 @@ pub enum ConnectionStatus {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceCapabilities {
|
||||
#[serde(default = "default_true")]
|
||||
@@ -36,32 +35,59 @@ pub struct DeviceCapabilities {
|
||||
pub vertical_swing: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub horizontal_swing: bool,
|
||||
#[serde(default)] pub turbo: bool,
|
||||
#[serde(default)] pub quiet: bool,
|
||||
#[serde(default)] pub sleep: bool,
|
||||
#[serde(default)] pub light: bool,
|
||||
#[serde(default)] pub health: bool,
|
||||
#[serde(default)] pub buzzer_control: bool,
|
||||
#[serde(default)] pub energy_meter: bool,
|
||||
#[serde(default)] pub compressor_frequency: bool,
|
||||
#[serde(default)]
|
||||
pub turbo: bool,
|
||||
#[serde(default)]
|
||||
pub quiet: bool,
|
||||
#[serde(default)]
|
||||
pub sleep: bool,
|
||||
#[serde(default)]
|
||||
pub light: bool,
|
||||
#[serde(default)]
|
||||
pub health: bool,
|
||||
#[serde(default)]
|
||||
pub buzzer_control: bool,
|
||||
#[serde(default)]
|
||||
pub energy_meter: bool,
|
||||
#[serde(default)]
|
||||
pub compressor_frequency: bool,
|
||||
}
|
||||
|
||||
impl Default for DeviceCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
power: true, mode: true, fan_modes: default_fan_modes(),
|
||||
min_temperature: 8.0, max_temperature: 30.0, temperature_step: 1.0,
|
||||
vertical_swing: true, horizontal_swing: true, turbo: false, quiet: false,
|
||||
sleep: false, light: false, health: false, buzzer_control: false,
|
||||
energy_meter: false, compressor_frequency: false,
|
||||
power: true,
|
||||
mode: true,
|
||||
fan_modes: default_fan_modes(),
|
||||
min_temperature: 8.0,
|
||||
max_temperature: 30.0,
|
||||
temperature_step: 1.0,
|
||||
vertical_swing: true,
|
||||
horizontal_swing: true,
|
||||
turbo: false,
|
||||
quiet: false,
|
||||
sleep: false,
|
||||
light: false,
|
||||
health: false,
|
||||
buzzer_control: false,
|
||||
energy_meter: false,
|
||||
compressor_frequency: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_fan_modes() -> Vec<u8> { vec![0, 1, 2, 3, 4, 5] }
|
||||
fn default_min_temperature() -> f64 { 8.0 }
|
||||
fn default_max_temperature() -> f64 { 30.0 }
|
||||
fn default_temperature_step() -> f64 { 1.0 }
|
||||
fn default_fan_modes() -> Vec<u8> {
|
||||
vec![0, 1, 2, 3, 4, 5]
|
||||
}
|
||||
fn default_min_temperature() -> f64 {
|
||||
8.0
|
||||
}
|
||||
fn default_max_temperature() -> f64 {
|
||||
30.0
|
||||
}
|
||||
fn default_temperature_step() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -296,8 +322,13 @@ impl Device {
|
||||
communication_failures: 0,
|
||||
pending_command: false,
|
||||
capabilities: DeviceCapabilities {
|
||||
turbo: true, quiet: true, sleep: true, light: true, health: true,
|
||||
buzzer_control: true, ..DeviceCapabilities::default()
|
||||
turbo: true,
|
||||
quiet: true,
|
||||
sleep: true,
|
||||
light: true,
|
||||
health: true,
|
||||
buzzer_control: true,
|
||||
..DeviceCapabilities::default()
|
||||
},
|
||||
energy_source: EnergySourcePreference::Auto,
|
||||
ha_energy_entity_id: None,
|
||||
@@ -320,10 +351,16 @@ impl Device {
|
||||
self.capabilities.horizontal_swing = true;
|
||||
self.capabilities.temperature_step = 1.0;
|
||||
}
|
||||
self.capabilities.turbo = self.supports_turbo.unwrap_or(self.connection_type == ConnectionType::Local);
|
||||
self.capabilities.quiet = self.supports_quiet.unwrap_or(self.connection_type == ConnectionType::Local);
|
||||
self.capabilities.turbo = self
|
||||
.supports_turbo
|
||||
.unwrap_or(self.connection_type == ConnectionType::Local);
|
||||
self.capabilities.quiet = self
|
||||
.supports_quiet
|
||||
.unwrap_or(self.connection_type == ConnectionType::Local);
|
||||
self.capabilities.sleep = self.supports_sleep.unwrap_or(false);
|
||||
self.capabilities.light = self.supports_light.unwrap_or(self.connection_type == ConnectionType::Local);
|
||||
self.capabilities.light = self
|
||||
.supports_light
|
||||
.unwrap_or(self.connection_type == ConnectionType::Local);
|
||||
self.capabilities.health = self.supports_health.unwrap_or(false);
|
||||
self.capabilities.buzzer_control = self.supports_buzzer_control.unwrap_or(false);
|
||||
self.capabilities.energy_meter = self.supports_energy_meter.unwrap_or(false);
|
||||
@@ -391,8 +428,16 @@ impl DeviceCommand {
|
||||
.cloned(),
|
||||
target_temperature: self.target_temperature.filter(|value| {
|
||||
let step = device.capabilities.temperature_step.max(0.5);
|
||||
let requested = (value.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round();
|
||||
let current = (device.target_temperature.clamp(device.capabilities.min_temperature, device.capabilities.max_temperature) / step).round();
|
||||
let requested = (value.clamp(
|
||||
device.capabilities.min_temperature,
|
||||
device.capabilities.max_temperature,
|
||||
) / step)
|
||||
.round();
|
||||
let current = (device.target_temperature.clamp(
|
||||
device.capabilities.min_temperature,
|
||||
device.capabilities.max_temperature,
|
||||
) / step)
|
||||
.round();
|
||||
requested != current
|
||||
}),
|
||||
fan_speed: self
|
||||
|
||||
@@ -40,7 +40,6 @@ pub struct HaReading {
|
||||
pub temperature: f64,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkReading {
|
||||
pub id: i64,
|
||||
@@ -90,4 +89,3 @@ pub struct EnergyReading {
|
||||
#[serde(default)]
|
||||
pub reset_detected: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -168,4 +168,3 @@ pub struct SettingsSnapshot {
|
||||
pub home_assistant: HomeAssistantSettingsView,
|
||||
pub debug: DebugSettings,
|
||||
}
|
||||
|
||||
|
||||
+4
-12
@@ -151,10 +151,7 @@ fn persist_notification_status(
|
||||
};
|
||||
let updated = with_notification_status(metadata, status, reason, provider);
|
||||
match state.db.update_event_metadata(event_id, &updated) {
|
||||
Ok(true) => state.broadcast(
|
||||
"log.updated",
|
||||
json!({"id": event_id, "metadata": updated}),
|
||||
),
|
||||
Ok(true) => state.broadcast("log.updated", json!({"id": event_id, "metadata": updated})),
|
||||
Ok(false) => tracing::warn!(event_id, "cannot update missing event log metadata"),
|
||||
Err(err) => tracing::warn!(event_id, error=?err, "cannot update event log metadata"),
|
||||
}
|
||||
@@ -224,14 +221,9 @@ pub async fn dispatch(
|
||||
_ => Err("unsupported notification provider".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => persist_notification_status(
|
||||
&state,
|
||||
event_id,
|
||||
&metadata,
|
||||
"sent",
|
||||
None,
|
||||
&cfg.provider,
|
||||
),
|
||||
Ok(()) => {
|
||||
persist_notification_status(&state, event_id, &metadata, "sent", None, &cfg.provider)
|
||||
}
|
||||
Err(err) => {
|
||||
persist_notification_status(
|
||||
&state,
|
||||
|
||||
@@ -199,11 +199,7 @@ impl GreeClient {
|
||||
}
|
||||
if let Some(v) = command.quiet {
|
||||
opt.push("Quiet");
|
||||
values.push(json!(if v {
|
||||
quiet_on_value.clamp(1, 3)
|
||||
} else {
|
||||
0
|
||||
}));
|
||||
values.push(json!(if v { quiet_on_value.clamp(1, 3) } else { 0 }));
|
||||
}
|
||||
if let Some(v) = command.turbo {
|
||||
opt.push("Tur");
|
||||
|
||||
@@ -11,12 +11,16 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Conf
|
||||
use std::{ffi::CStr, ptr};
|
||||
unsafe {
|
||||
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
|
||||
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
|
||||
if libc::getifaddrs(&mut addrs) != 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("getifaddrs failed");
|
||||
}
|
||||
let mut current = addrs;
|
||||
let mut best: Option<LocalIpv4Config> = None;
|
||||
while !current.is_null() {
|
||||
let ifa = &*current;
|
||||
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null()
|
||||
if !ifa.ifa_name.is_null()
|
||||
&& !ifa.ifa_addr.is_null()
|
||||
&& !ifa.ifa_netmask.is_null()
|
||||
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
|
||||
{
|
||||
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
|
||||
@@ -35,7 +39,11 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Conf
|
||||
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
|
||||
prefix_len,
|
||||
};
|
||||
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) {
|
||||
if best
|
||||
.as_ref()
|
||||
.map(|current| prefix_len > current.prefix_len)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +56,9 @@ fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Conf
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) }
|
||||
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn interface_ipv4_config(selector: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
@@ -56,7 +66,9 @@ fn interface_ipv4_config(selector: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
let requested_ip = selector.parse::<Ipv4Addr>().ok();
|
||||
unsafe {
|
||||
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
|
||||
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
|
||||
if libc::getifaddrs(&mut addrs) != 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("getifaddrs failed");
|
||||
}
|
||||
let mut current = addrs;
|
||||
let mut found = None;
|
||||
while !current.is_null() {
|
||||
@@ -73,7 +85,9 @@ fn interface_ipv4_config(selector: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
|
||||
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
|
||||
Ipv4Addr::from(u32::from(ip) | !u32::from(mask))
|
||||
} else { Ipv4Addr::BROADCAST };
|
||||
} else {
|
||||
Ipv4Addr::BROADCAST
|
||||
};
|
||||
found = Some((ip, broadcast));
|
||||
break;
|
||||
}
|
||||
@@ -90,10 +104,19 @@ fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
bail!("GREE interface binding is only supported on Linux (requested {interface})")
|
||||
}
|
||||
|
||||
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> { interface_ipv4_config(interface).map(|(ip, _)| ip) }
|
||||
fn value_as_i64(value: &Value) -> Option<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) }
|
||||
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> {
|
||||
interface_ipv4_config(interface).map(|(ip, _)| ip)
|
||||
}
|
||||
fn value_as_i64(value: &Value) -> Option<i64> {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_str()?.trim().parse().ok())
|
||||
}
|
||||
fn value_as_f64(value: &Value) -> Option<f64> {
|
||||
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite())
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_str()?.trim().parse().ok())
|
||||
.filter(|value| value.is_finite())
|
||||
}
|
||||
fn status_i64(name: &str, value: &Value) -> Result<i64> {
|
||||
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
|
||||
@@ -121,11 +144,23 @@ fn status_feature_flag(name: &str, value: &Value) -> Result<bool> {
|
||||
}
|
||||
Ok(raw != 0)
|
||||
}
|
||||
fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } }
|
||||
fn mode_name_checked(value: i64) -> Option<&'static str> {
|
||||
match value {
|
||||
0 => Some("auto"),
|
||||
1 => Some("cool"),
|
||||
2 => Some("dry"),
|
||||
3 => Some("fan"),
|
||||
4 => Some("heat"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn mode_value(value: &str) -> Result<i64> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
|
||||
"auto" => Ok(0),
|
||||
"cool" => Ok(1),
|
||||
"dry" => Ok(2),
|
||||
"fan" => Ok(3),
|
||||
"heat" => Ok(4),
|
||||
_ => bail!("unsupported mode: {value}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ mod tests {
|
||||
"cols": ["Pow", "Quiet"],
|
||||
"dat": [1, 2]
|
||||
});
|
||||
client.apply_status(&mut device, &response).expect("Quiet=2 status");
|
||||
client
|
||||
.apply_status(&mut device, &response)
|
||||
.expect("Quiet=2 status");
|
||||
|
||||
assert!(device.power);
|
||||
assert!(device.quiet);
|
||||
@@ -87,7 +89,10 @@ mod tests {
|
||||
)
|
||||
.expect("quiet command payload");
|
||||
|
||||
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["Quiet"])));
|
||||
assert_eq!(
|
||||
payload.get("opt").cloned(),
|
||||
Some(serde_json::json!(["Quiet"]))
|
||||
);
|
||||
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([2])));
|
||||
}
|
||||
|
||||
@@ -105,7 +110,9 @@ mod tests {
|
||||
"cols": ["Pow", "Air"],
|
||||
"dat": [1, 3]
|
||||
});
|
||||
client.apply_status(&mut device, &response).expect("Air=3 status");
|
||||
client
|
||||
.apply_status(&mut device, &response)
|
||||
.expect("Air=3 status");
|
||||
|
||||
assert!(device.power);
|
||||
assert!(device.air);
|
||||
|
||||
+28
-14
@@ -117,7 +117,11 @@ impl GreeCloudApi {
|
||||
.await
|
||||
.context("GREE Cloud login request failed")?;
|
||||
|
||||
if data.get("r").and_then(Value::as_i64).is_some_and(|r| r != 200) {
|
||||
if data
|
||||
.get("r")
|
||||
.and_then(Value::as_i64)
|
||||
.is_some_and(|r| r != 200)
|
||||
{
|
||||
let message = data
|
||||
.get("msg")
|
||||
.and_then(Value::as_str)
|
||||
@@ -230,8 +234,14 @@ impl GreeCloudApi {
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
async fn request_now(&self, endpoint: &str, payload: Value, hash_props: &[&str]) -> Result<Value> {
|
||||
self.request_at(endpoint, payload, hash_props, Utc::now()).await
|
||||
async fn request_now(
|
||||
&self,
|
||||
endpoint: &str,
|
||||
payload: Value,
|
||||
hash_props: &[&str],
|
||||
) -> Result<Value> {
|
||||
self.request_at(endpoint, payload, hash_props, Utc::now())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn request_at(
|
||||
@@ -318,7 +328,11 @@ fn filter_duplicate_devices(devices: Vec<CloudDeviceInfo>) -> Vec<CloudDeviceInf
|
||||
filtered.extend(preferred);
|
||||
}
|
||||
}
|
||||
filtered.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()));
|
||||
filtered.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&b.name.to_ascii_lowercase())
|
||||
});
|
||||
filtered
|
||||
}
|
||||
|
||||
@@ -344,9 +358,12 @@ fn value_bool(value: &Value) -> bool {
|
||||
.as_bool()
|
||||
.or_else(|| value.as_i64().map(|v| v != 0))
|
||||
.or_else(|| {
|
||||
value
|
||||
.as_str()
|
||||
.map(|v| !matches!(v.trim().to_ascii_lowercase().as_str(), "0" | "false" | "off" | ""))
|
||||
value.as_str().map(|v| {
|
||||
!matches!(
|
||||
v.trim().to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "off" | ""
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
@@ -383,9 +400,9 @@ fn md5_hex(input: &str) -> String {
|
||||
|
||||
fn md5(input: &[u8]) -> [u8; 16] {
|
||||
const S: [u32; 64] = [
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9,
|
||||
14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4,
|
||||
11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5,
|
||||
9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10,
|
||||
15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
|
||||
];
|
||||
const K: [u32; 64] = [
|
||||
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613,
|
||||
@@ -432,10 +449,7 @@ fn md5(input: &[u8]) -> [u8; 16] {
|
||||
32..=47 => (b ^ c ^ d, (3 * i + 5) % 16),
|
||||
_ => (c ^ (b | (!d)), (7 * i) % 16),
|
||||
};
|
||||
let next = a
|
||||
.wrapping_add(f)
|
||||
.wrapping_add(K[i])
|
||||
.wrapping_add(m[g]);
|
||||
let next = a.wrapping_add(f).wrapping_add(K[i]).wrapping_add(m[g]);
|
||||
a = d;
|
||||
d = c;
|
||||
c = b;
|
||||
|
||||
@@ -13,8 +13,8 @@ use tokio::{
|
||||
sync::{broadcast, mpsc},
|
||||
time::{interval, timeout},
|
||||
};
|
||||
use tokio_rustls::{rustls, TlsConnector};
|
||||
use tokio_rustls::rustls::pki_types::ServerName;
|
||||
use tokio_rustls::{rustls, TlsConnector};
|
||||
|
||||
pub const MQTT_PORT: u16 = 1984;
|
||||
pub const MQTT_KEEPALIVE_SECONDS: u16 = 60;
|
||||
@@ -61,11 +61,18 @@ pub struct MqttDeviceEnvelope {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MqttEvent {
|
||||
Message { topic: String, payload: Vec<u8> },
|
||||
Message {
|
||||
topic: String,
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Broker-level traffic such as SUBACK/PUBACK/PINGRESP. This proves the MQTT
|
||||
/// session is alive without being mistaken for a response from the HVAC unit.
|
||||
Traffic { kind: &'static str },
|
||||
Disconnected { reason: String },
|
||||
Traffic {
|
||||
kind: &'static str,
|
||||
},
|
||||
Disconnected {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -150,13 +157,7 @@ impl MqttConnection {
|
||||
let connected = Arc::new(AtomicBool::new(true));
|
||||
let packet_ids = Arc::new(AtomicU16::new(1));
|
||||
let last_rx_ms = Arc::new(AtomicU64::new(unix_millis()));
|
||||
spawn_writer(
|
||||
writer,
|
||||
rx,
|
||||
connected.clone(),
|
||||
events.clone(),
|
||||
packet_ids,
|
||||
);
|
||||
spawn_writer(writer, rx, connected.clone(), events.clone(), packet_ids);
|
||||
spawn_reader(
|
||||
reader,
|
||||
tx.clone(),
|
||||
@@ -230,7 +231,9 @@ impl MqttConnection {
|
||||
Ok(MqttEvent::Disconnected { reason }) => break Err(anyhow!("{}", reason)),
|
||||
Ok(_) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(broadcast::error::RecvError::Closed) => break Err(anyhow!("GREE Cloud MQTT event stream closed")),
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
break Err(anyhow!("GREE Cloud MQTT event stream closed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -365,15 +368,21 @@ fn spawn_reader<R>(
|
||||
Ok((header, payload)) => {
|
||||
last_rx_ms.store(unix_millis(), Ordering::Release);
|
||||
match header >> 4 {
|
||||
3 => {
|
||||
if let Err(err) = handle_publish(header, &payload, &tx, &events).await {
|
||||
tracing::warn!(error=?err, "invalid GREE Cloud MQTT PUBLISH");
|
||||
3 => {
|
||||
if let Err(err) = handle_publish(header, &payload, &tx, &events).await {
|
||||
tracing::warn!(error=?err, "invalid GREE Cloud MQTT PUBLISH");
|
||||
}
|
||||
}
|
||||
}
|
||||
9 => { let _ = events.send(MqttEvent::Traffic { kind: "SUBACK" }); }
|
||||
4 => { let _ = events.send(MqttEvent::Traffic { kind: "PUBACK" }); }
|
||||
13 => { let _ = events.send(MqttEvent::Traffic { kind: "PINGRESP" }); }
|
||||
_ => {}
|
||||
9 => {
|
||||
let _ = events.send(MqttEvent::Traffic { kind: "SUBACK" });
|
||||
}
|
||||
4 => {
|
||||
let _ = events.send(MqttEvent::Traffic { kind: "PUBACK" });
|
||||
}
|
||||
13 => {
|
||||
let _ = events.send(MqttEvent::Traffic { kind: "PINGRESP" });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -415,7 +424,10 @@ async fn handle_publish(
|
||||
}
|
||||
}
|
||||
let body = payload[offset..].to_vec();
|
||||
let _ = events.send(MqttEvent::Message { topic, payload: body });
|
||||
let _ = events.send(MqttEvent::Message {
|
||||
topic,
|
||||
payload: body,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -438,7 +450,11 @@ fn unix_millis() -> u64 {
|
||||
|
||||
fn next_packet_id(ids: &AtomicU16) -> u16 {
|
||||
let id = ids.fetch_add(1, Ordering::Relaxed);
|
||||
if id == 0 { 1 } else { id }
|
||||
if id == 0 {
|
||||
1
|
||||
} else {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_disconnected(
|
||||
@@ -451,7 +467,12 @@ fn mark_disconnected(
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_connect(client_id: &str, username: &str, password: &str, keepalive: u16) -> Result<Vec<u8>> {
|
||||
fn encode_connect(
|
||||
client_id: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
keepalive: u16,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut body = Vec::new();
|
||||
push_utf8(&mut body, "MQTT")?;
|
||||
body.push(4); // MQTT 3.1.1
|
||||
@@ -548,7 +569,13 @@ mod tests {
|
||||
#[test]
|
||||
fn region_brokers_match_cloud_reference() {
|
||||
assert_eq!(broker_for_region("Europe"), Some("mqtt-eu.gree.com"));
|
||||
assert_eq!(broker_for_region("North American"), Some("mqtt-na.gree.com"));
|
||||
assert_eq!(broker_for_region("China Mainland"), Some("mqtt-cn.gree.com"));
|
||||
assert_eq!(
|
||||
broker_for_region("North American"),
|
||||
Some("mqtt-na.gree.com")
|
||||
);
|
||||
assert_eq!(
|
||||
broker_for_region("China Mainland"),
|
||||
Some("mqtt-cn.gree.com")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+518
-150
@@ -1,10 +1,14 @@
|
||||
use crate::{
|
||||
models::{ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings},
|
||||
models::{
|
||||
ApiEvent, ConnectionStatus, ConnectionType, Device, DeviceCommand, GreeCloudSettings,
|
||||
},
|
||||
protocol::{
|
||||
crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2},
|
||||
gree::{BindResult, GreeClient},
|
||||
gree_cloud::{parent_mac, CloudCredentials, GreeCloudApi},
|
||||
gree_cloud_mqtt::{broker_for_region, MqttConnection, MqttDeviceEnvelope, MqttEvent, MQTT_PORT},
|
||||
gree_cloud_mqtt::{
|
||||
broker_for_region, MqttConnection, MqttDeviceEnvelope, MqttEvent, MQTT_PORT,
|
||||
},
|
||||
},
|
||||
};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
@@ -33,10 +37,37 @@ const CLOUD_MAX_CONCURRENT_REQUESTS: usize = 8;
|
||||
const CLOUD_QUEUE_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const CLOUD_DEVICE_LOCK_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const KNOWN_CLOUD_PROPERTIES: &[&str] = &[
|
||||
"Pow", "Mod", "Dwet", "DwatSen", "Dfltr", "DwatFul", "Dmod", "SetTem", "TemSen",
|
||||
"TemUn", "TemRec", "HalfTemEn", "SetDeciTem", "Add0.5", "WdSpd", "Air", "Blo",
|
||||
"Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur", "StHt",
|
||||
"SvSt", "HeatCoolType", "hid", "ElcAll", "CompressorFqy",
|
||||
"Pow",
|
||||
"Mod",
|
||||
"Dwet",
|
||||
"DwatSen",
|
||||
"Dfltr",
|
||||
"DwatFul",
|
||||
"Dmod",
|
||||
"SetTem",
|
||||
"TemSen",
|
||||
"TemUn",
|
||||
"TemRec",
|
||||
"HalfTemEn",
|
||||
"SetDeciTem",
|
||||
"Add0.5",
|
||||
"WdSpd",
|
||||
"Air",
|
||||
"Blo",
|
||||
"Health",
|
||||
"SwhSlp",
|
||||
"SlpMod",
|
||||
"Lig",
|
||||
"SwingLfRig",
|
||||
"SwUpDn",
|
||||
"Quiet",
|
||||
"Tur",
|
||||
"StHt",
|
||||
"SvSt",
|
||||
"HeatCoolType",
|
||||
"hid",
|
||||
"ElcAll",
|
||||
"CompressorFqy",
|
||||
];
|
||||
// Property set used by older GREE Wi-Fi modules before the dehumidifier,
|
||||
// half-degree and cloud energy fields were added. Some V1.x modules silently
|
||||
@@ -45,9 +76,26 @@ const KNOWN_CLOUD_PROPERTIES: &[&str] = &[
|
||||
// This list matches the long-standing greeclimate status schema used by those
|
||||
// modules and intentionally excludes newer optional fields.
|
||||
const LEGACY_CLOUD_PROPERTIES: &[&str] = &[
|
||||
"Pow", "Mod", "SetTem", "TemSen", "TemUn", "TemRec", "WdSpd", "Air", "Blo",
|
||||
"Health", "SwhSlp", "SlpMod", "Lig", "SwingLfRig", "SwUpDn", "Quiet", "Tur",
|
||||
"StHt", "SvSt", "HeatCoolType",
|
||||
"Pow",
|
||||
"Mod",
|
||||
"SetTem",
|
||||
"TemSen",
|
||||
"TemUn",
|
||||
"TemRec",
|
||||
"WdSpd",
|
||||
"Air",
|
||||
"Blo",
|
||||
"Health",
|
||||
"SwhSlp",
|
||||
"SlpMod",
|
||||
"Lig",
|
||||
"SwingLfRig",
|
||||
"SwUpDn",
|
||||
"Quiet",
|
||||
"Tur",
|
||||
"StHt",
|
||||
"SvSt",
|
||||
"HeatCoolType",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -314,7 +362,8 @@ impl GreeCloudProvider {
|
||||
let session = self.current_session().await?;
|
||||
let parent = cloud_wire_parent(&session, device);
|
||||
session.mqtt.subscribe_device(&parent).await?;
|
||||
self.set_subscriptions(device, &session.broker, &parent).await;
|
||||
self.set_subscriptions(device, &session.broker, &parent)
|
||||
.await;
|
||||
|
||||
let response = self.poll_status_compatible(device, &session).await?;
|
||||
apply_cloud_capability_snapshot(device, &response.properties);
|
||||
@@ -327,7 +376,8 @@ impl GreeCloudProvider {
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_cloud_sync = Some(Utc::now());
|
||||
device.updated_at = Utc::now();
|
||||
self.update_diagnostics_from_message(device, &response).await;
|
||||
self.update_diagnostics_from_message(device, &response)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -394,10 +444,16 @@ impl GreeCloudProvider {
|
||||
let alternate_cipher = if current_cipher == 2 { 1 } else { 2 };
|
||||
let alternate_wire = alternate_wire_mac_case(¤t_wire);
|
||||
let (cipher, wire, profile) = match step {
|
||||
1 => (alternate_cipher, current_wire.clone(), "legacy-cipher-probe"),
|
||||
1 => (
|
||||
alternate_cipher,
|
||||
current_wire.clone(),
|
||||
"legacy-cipher-probe",
|
||||
),
|
||||
2 => (
|
||||
current_cipher,
|
||||
alternate_wire.clone().unwrap_or_else(|| current_wire.clone()),
|
||||
alternate_wire
|
||||
.clone()
|
||||
.unwrap_or_else(|| current_wire.clone()),
|
||||
"legacy-case-probe",
|
||||
),
|
||||
3 => (
|
||||
@@ -424,17 +480,13 @@ impl GreeCloudProvider {
|
||||
self.remember_status_compatibility(device, &wire, "legacy")
|
||||
.await;
|
||||
if wire != current_wire {
|
||||
self.set_subscriptions(device, &session.broker, &parent).await;
|
||||
self.set_subscriptions(device, &session.broker, &parent)
|
||||
.await;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn remember_status_compatibility(
|
||||
&self,
|
||||
device: &Device,
|
||||
wire_mac: &str,
|
||||
profile: &str,
|
||||
) {
|
||||
async fn remember_status_compatibility(&self, device: &Device, wire_mac: &str, profile: &str) {
|
||||
let stable = cloud_id(device);
|
||||
if let Some(session) = self.inner.session.write().await.as_mut() {
|
||||
session.wire_macs.insert(stable, wire_mac.to_string());
|
||||
@@ -463,17 +515,24 @@ impl GreeCloudProvider {
|
||||
let session = self.current_session().await?;
|
||||
let parent = cloud_wire_parent(&session, device);
|
||||
session.mqtt.subscribe_device(&parent).await?;
|
||||
self.set_subscriptions(device, &session.broker, &parent).await;
|
||||
self.set_subscriptions(device, &session.broker, &parent)
|
||||
.await;
|
||||
|
||||
let commands = self.build_command_sequence(device, command, suppress_beep).await?;
|
||||
let commands = self
|
||||
.build_command_sequence(device, command, suppress_beep)
|
||||
.await?;
|
||||
let mut applied = DeviceCommand::default();
|
||||
for (opt, values, fragment) in commands {
|
||||
let payload = json!({"t":"cmd", "opt": opt, "p": values});
|
||||
// greeclimate treats a 2s no-ACK as uncertain success. Preserve that semantic,
|
||||
// then let the engine's bounded status verification decide the final UI state.
|
||||
match self.request(device, &session, payload, CLOUD_COMMAND_TIMEOUT).await {
|
||||
match self
|
||||
.request(device, &session, payload, CLOUD_COMMAND_TIMEOUT)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
self.update_diagnostics_from_message(device, &response).await;
|
||||
self.update_diagnostics_from_message(device, &response)
|
||||
.await;
|
||||
fragment.apply(device);
|
||||
merge_command(&mut applied, &fragment);
|
||||
}
|
||||
@@ -497,7 +556,14 @@ impl GreeCloudProvider {
|
||||
let mut out = Vec::new();
|
||||
if let Some(mode) = command.mode.as_deref() {
|
||||
let value = mode_to_wire(mode)?;
|
||||
out.push((vec!["Mod".into()], vec![json!(value)], DeviceCommand { mode: Some(mode.into()), ..Default::default() }));
|
||||
out.push((
|
||||
vec!["Mod".into()],
|
||||
vec![json!(value)],
|
||||
DeviceCommand {
|
||||
mode: Some(mode.into()),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(target) = command.target_temperature {
|
||||
@@ -513,24 +579,121 @@ impl GreeCloudProvider {
|
||||
opt.push("Add0.5".into());
|
||||
values.push(json!(half));
|
||||
}
|
||||
out.push((opt, values, DeviceCommand { target_temperature: Some(target), ..Default::default() }));
|
||||
out.push((
|
||||
opt,
|
||||
values,
|
||||
DeviceCommand {
|
||||
target_temperature: Some(target),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let mut simple = Vec::new();
|
||||
if let Some(value) = command.fan_speed { simple.push(("WdSpd", json!(value.min(5)), DeviceCommand { fan_speed: Some(value.min(5)), ..Default::default() })); }
|
||||
if let Some(value) = command.swing_vertical { simple.push(("SwUpDn", json!(if value { 1 } else { 0 }), DeviceCommand { swing_vertical: Some(value), ..Default::default() })); }
|
||||
if let Some(value) = command.swing_horizontal { simple.push(("SwingLfRig", json!(if value { 1 } else { 0 }), DeviceCommand { swing_horizontal: Some(value), ..Default::default() })); }
|
||||
if let Some(value) = command.quiet {
|
||||
let wire = if value { device.quiet_wire_value.unwrap_or(2).max(1) } else { 0 };
|
||||
simple.push(("Quiet", json!(wire), DeviceCommand { quiet: Some(value), ..Default::default() }));
|
||||
if let Some(value) = command.fan_speed {
|
||||
simple.push((
|
||||
"WdSpd",
|
||||
json!(value.min(5)),
|
||||
DeviceCommand {
|
||||
fan_speed: Some(value.min(5)),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.swing_vertical {
|
||||
simple.push((
|
||||
"SwUpDn",
|
||||
json!(if value { 1 } else { 0 }),
|
||||
DeviceCommand {
|
||||
swing_vertical: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.swing_horizontal {
|
||||
simple.push((
|
||||
"SwingLfRig",
|
||||
json!(if value { 1 } else { 0 }),
|
||||
DeviceCommand {
|
||||
swing_horizontal: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.quiet {
|
||||
let wire = if value {
|
||||
device.quiet_wire_value.unwrap_or(2).max(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
simple.push((
|
||||
"Quiet",
|
||||
json!(wire),
|
||||
DeviceCommand {
|
||||
quiet: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.turbo {
|
||||
simple.push((
|
||||
"Tur",
|
||||
json!(u8::from(value)),
|
||||
DeviceCommand {
|
||||
turbo: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.light {
|
||||
simple.push((
|
||||
"Lig",
|
||||
json!(u8::from(value)),
|
||||
DeviceCommand {
|
||||
light: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.air {
|
||||
simple.push((
|
||||
"Air",
|
||||
json!(u8::from(value)),
|
||||
DeviceCommand {
|
||||
air: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.xfan {
|
||||
simple.push((
|
||||
"Blo",
|
||||
json!(u8::from(value)),
|
||||
DeviceCommand {
|
||||
xfan: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.health {
|
||||
simple.push((
|
||||
"Health",
|
||||
json!(u8::from(value)),
|
||||
DeviceCommand {
|
||||
health: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
if let Some(value) = command.turbo { simple.push(("Tur", json!(u8::from(value)), DeviceCommand { turbo: Some(value), ..Default::default() })); }
|
||||
if let Some(value) = command.light { simple.push(("Lig", json!(u8::from(value)), DeviceCommand { light: Some(value), ..Default::default() })); }
|
||||
if let Some(value) = command.air { simple.push(("Air", json!(u8::from(value)), DeviceCommand { air: Some(value), ..Default::default() })); }
|
||||
if let Some(value) = command.xfan { simple.push(("Blo", json!(u8::from(value)), DeviceCommand { xfan: Some(value), ..Default::default() })); }
|
||||
if let Some(value) = command.health { simple.push(("Health", json!(u8::from(value)), DeviceCommand { health: Some(value), ..Default::default() })); }
|
||||
if let Some(value) = command.sleep {
|
||||
simple.push(("SwhSlp", json!(u8::from(value)), DeviceCommand { sleep: Some(value), ..Default::default() }));
|
||||
simple.push((
|
||||
"SwhSlp",
|
||||
json!(u8::from(value)),
|
||||
DeviceCommand {
|
||||
sleep: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
simple.push(("SlpMod", json!(u8::from(value)), DeviceCommand::default()));
|
||||
}
|
||||
for (name, value, fragment) in simple {
|
||||
@@ -538,7 +701,14 @@ impl GreeCloudProvider {
|
||||
}
|
||||
|
||||
if let Some(value) = command.power {
|
||||
out.push((vec!["Pow".into()], vec![json!(u8::from(value))], DeviceCommand { power: Some(value), ..Default::default() }));
|
||||
out.push((
|
||||
vec!["Pow".into()],
|
||||
vec![json!(u8::from(value))],
|
||||
DeviceCommand {
|
||||
power: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// greeclimate adds Buzzer_ON_OFF=1 to every state command when its shared
|
||||
@@ -614,13 +784,21 @@ impl GreeCloudProvider {
|
||||
.ok_or_else(|| anyhow!("GREE Cloud MQTT is disconnected"))
|
||||
}
|
||||
|
||||
pub async fn probe_mqtt(&self, settings: &GreeCloudSettings, devices: &[Device]) -> Result<u64> {
|
||||
pub async fn probe_mqtt(
|
||||
&self,
|
||||
settings: &GreeCloudSettings,
|
||||
devices: &[Device],
|
||||
) -> Result<u64> {
|
||||
self.ensure_connected(settings, devices).await?;
|
||||
let session = self.current_session().await?;
|
||||
session.mqtt.ping_round_trip().await
|
||||
}
|
||||
|
||||
pub async fn ensure_connected(&self, settings: &GreeCloudSettings, devices: &[Device]) -> Result<()> {
|
||||
pub async fn ensure_connected(
|
||||
&self,
|
||||
settings: &GreeCloudSettings,
|
||||
devices: &[Device],
|
||||
) -> Result<()> {
|
||||
if !settings.enabled {
|
||||
bail!("GREE Cloud is disabled");
|
||||
}
|
||||
@@ -637,9 +815,12 @@ impl GreeCloudProvider {
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
timeout(CLOUD_CONNECT_TIMEOUT + Duration::from_secs(5), connection_finished)
|
||||
.await
|
||||
.context("timed out waiting for GREE Cloud connection attempt")?;
|
||||
timeout(
|
||||
CLOUD_CONNECT_TIMEOUT + Duration::from_secs(5),
|
||||
connection_finished,
|
||||
)
|
||||
.await
|
||||
.context("timed out waiting for GREE Cloud connection attempt")?;
|
||||
return if self.is_connected().await {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -824,7 +1005,10 @@ impl GreeCloudProvider {
|
||||
"parents": parents.len(),
|
||||
}));
|
||||
let mut diagnostics = self.inner.diagnostics.write().await;
|
||||
for device in devices.iter().filter(|d| d.connection_type == ConnectionType::GreeCloud) {
|
||||
for device in devices
|
||||
.iter()
|
||||
.filter(|d| d.connection_type == ConnectionType::GreeCloud)
|
||||
{
|
||||
let item = diagnostics.entry(device.id.clone()).or_default();
|
||||
item.mqtt_state = "connected".into();
|
||||
item.last_connect = Some(now);
|
||||
@@ -836,7 +1020,10 @@ impl GreeCloudProvider {
|
||||
}
|
||||
}
|
||||
tracing::info!(broker=%broker, "GREE Cloud MQTT connected");
|
||||
tracing::info!(count=parents.len(), "GREE Cloud MQTT subscriptions restored");
|
||||
tracing::info!(
|
||||
count = parents.len(),
|
||||
"GREE Cloud MQTT subscriptions restored"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -847,16 +1034,8 @@ impl GreeCloudProvider {
|
||||
inner_payload: Value,
|
||||
wait: Duration,
|
||||
) -> Result<ParsedCloudMessage> {
|
||||
self.request_with_options(
|
||||
device,
|
||||
session,
|
||||
inner_payload,
|
||||
wait,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
self.request_with_options(device, session, inner_payload, wait, None, None, None)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn request_with_options(
|
||||
@@ -883,7 +1062,8 @@ impl GreeCloudProvider {
|
||||
.map(String::as_str)
|
||||
.or(device.key.as_deref())
|
||||
.ok_or_else(|| anyhow!("GREE Cloud device key is missing"))?;
|
||||
let cipher = cipher_override.unwrap_or_else(|| if device.protocol_version == 2 { 2 } else { 1 });
|
||||
let cipher =
|
||||
cipher_override.unwrap_or_else(|| if device.protocol_version == 2 { 2 } else { 1 });
|
||||
let plaintext = serde_json::to_vec(&inner_payload)?;
|
||||
let (pack, tag) = if cipher == 2 {
|
||||
let encrypted = encrypt_v2(key, &plaintext)?;
|
||||
@@ -1139,7 +1319,10 @@ impl GreeCloudProvider {
|
||||
// and must be ignored rather than reported as a payload error every few seconds.
|
||||
let registered = self.inner.registered.read().await.clone();
|
||||
if registered.is_empty() {
|
||||
tracing::debug!(topic, "ignoring GREE Cloud MQTT payload without registered devices");
|
||||
tracing::debug!(
|
||||
topic,
|
||||
"ignoring GREE Cloud MQTT payload without registered devices"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if topic.starts_with("connect/") {
|
||||
@@ -1158,7 +1341,8 @@ impl GreeCloudProvider {
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let envelope: MqttDeviceEnvelope = serde_json::from_slice(payload).context("invalid GREE Cloud MQTT JSON")?;
|
||||
let envelope: MqttDeviceEnvelope =
|
||||
serde_json::from_slice(payload).context("invalid GREE Cloud MQTT JSON")?;
|
||||
if envelope.pack.is_empty() {
|
||||
bail!("GREE Cloud MQTT payload has no encrypted pack");
|
||||
}
|
||||
@@ -1181,28 +1365,40 @@ impl GreeCloudProvider {
|
||||
.filter(|(_, item)| item.parent_mac.eq_ignore_ascii_case(&parent_from_topic))
|
||||
.map(|(id, item)| (id.clone(), item.clone()))
|
||||
.collect();
|
||||
candidates.sort_by_key(|(id, _)| if !normalized_tcid.is_empty() && id.eq_ignore_ascii_case(&normalized_tcid) { 0 } else { 1 });
|
||||
candidates.sort_by_key(|(id, _)| {
|
||||
if !normalized_tcid.is_empty() && id.eq_ignore_ascii_case(&normalized_tcid) {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
});
|
||||
if candidates.is_empty() {
|
||||
bail!("GREE Cloud MQTT message does not match a registered device");
|
||||
}
|
||||
|
||||
let mut last_error = None;
|
||||
for (cloud_id, registered) in candidates {
|
||||
let preferred = if envelope.tag.is_some() { 2 } else { registered.cipher_version };
|
||||
let (plaintext, cipher_version) = match decrypt_cloud_payload(®istered.key, &envelope, preferred) {
|
||||
Ok(value) => (value, preferred),
|
||||
Err(first) => {
|
||||
let alternate = if preferred == 2 { 1 } else { 2 };
|
||||
match decrypt_cloud_payload(®istered.key, &envelope, alternate) {
|
||||
Ok(value) => (value, alternate),
|
||||
Err(second) => {
|
||||
last_error = Some(format!("{first}; alternate cipher: {second}"));
|
||||
continue;
|
||||
let preferred = if envelope.tag.is_some() {
|
||||
2
|
||||
} else {
|
||||
registered.cipher_version
|
||||
};
|
||||
let (plaintext, cipher_version) =
|
||||
match decrypt_cloud_payload(®istered.key, &envelope, preferred) {
|
||||
Ok(value) => (value, preferred),
|
||||
Err(first) => {
|
||||
let alternate = if preferred == 2 { 1 } else { 2 };
|
||||
match decrypt_cloud_payload(®istered.key, &envelope, alternate) {
|
||||
Ok(value) => (value, alternate),
|
||||
Err(second) => {
|
||||
last_error = Some(format!("{first}; alternate cipher: {second}"));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let parsed: Value = serde_json::from_slice(&plaintext).context("invalid decrypted GREE Cloud JSON")?;
|
||||
};
|
||||
let parsed: Value =
|
||||
serde_json::from_slice(&plaintext).context("invalid decrypted GREE Cloud JSON")?;
|
||||
validate_cloud_response(&parsed)?;
|
||||
let properties = properties_from_payload(&parsed)?;
|
||||
let message = ParsedCloudMessage {
|
||||
@@ -1235,17 +1431,25 @@ impl GreeCloudProvider {
|
||||
if !properties.is_empty() {
|
||||
item.last_status_timestamp = Some(now);
|
||||
for (key, value) in &properties {
|
||||
item.raw_device_properties.insert(key.clone(), value.clone());
|
||||
item.raw_device_properties
|
||||
.insert(key.clone(), value.clone());
|
||||
}
|
||||
item.parsed_device_properties = parsed_properties(&item.raw_device_properties);
|
||||
item.unknown_properties = item.raw_device_properties
|
||||
item.unknown_properties = item
|
||||
.raw_device_properties
|
||||
.iter()
|
||||
.filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str()))
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
item.energy_related_properties = item.raw_device_properties
|
||||
item.energy_related_properties = item
|
||||
.raw_device_properties
|
||||
.iter()
|
||||
.filter(|(key, _)| matches!(key.as_str(), "ElcAll" | "ElcAllConsumption" | "CompressorFqy"))
|
||||
.filter(|(key, _)| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"ElcAll" | "ElcAllConsumption" | "CompressorFqy"
|
||||
)
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
}
|
||||
@@ -1262,7 +1466,10 @@ impl GreeCloudProvider {
|
||||
}));
|
||||
return Ok(());
|
||||
}
|
||||
bail!("cannot decrypt GREE Cloud MQTT payload: {}", last_error.unwrap_or_else(|| "unknown cipher error".into()))
|
||||
bail!(
|
||||
"cannot decrypt GREE Cloud MQTT payload: {}",
|
||||
last_error.unwrap_or_else(|| "unknown cipher error".into())
|
||||
)
|
||||
}
|
||||
|
||||
async fn update_diagnostics_from_message(&self, device: &Device, message: &ParsedCloudMessage) {
|
||||
@@ -1274,17 +1481,25 @@ impl GreeCloudProvider {
|
||||
item.last_status_timestamp = Some(Utc::now());
|
||||
item.selected_cipher_version = Some(message.cipher_version);
|
||||
for (key, value) in &message.properties {
|
||||
item.raw_device_properties.insert(key.clone(), value.clone());
|
||||
item.raw_device_properties
|
||||
.insert(key.clone(), value.clone());
|
||||
}
|
||||
item.parsed_device_properties = parsed_properties(&item.raw_device_properties);
|
||||
item.unknown_properties = item.raw_device_properties
|
||||
item.unknown_properties = item
|
||||
.raw_device_properties
|
||||
.iter()
|
||||
.filter(|(key, _)| !KNOWN_CLOUD_PROPERTIES.contains(&key.as_str()))
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
item.energy_related_properties = item.raw_device_properties
|
||||
item.energy_related_properties = item
|
||||
.raw_device_properties
|
||||
.iter()
|
||||
.filter(|(key, _)| matches!(key.as_str(), "ElcAll" | "ElcAllConsumption" | "CompressorFqy"))
|
||||
.filter(|(key, _)| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"ElcAll" | "ElcAllConsumption" | "CompressorFqy"
|
||||
)
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
item.last_error = None;
|
||||
@@ -1367,7 +1582,10 @@ fn decrypt_cloud_payload(key: &str, envelope: &MqttDeviceEnvelope, cipher: u8) -
|
||||
2 => decrypt_v2(
|
||||
key,
|
||||
&envelope.pack,
|
||||
envelope.tag.as_deref().ok_or_else(|| anyhow!("GCM payload is missing tag"))?,
|
||||
envelope
|
||||
.tag
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("GCM payload is missing tag"))?,
|
||||
),
|
||||
_ => decrypt_v1(key, &envelope.pack),
|
||||
}
|
||||
@@ -1383,7 +1601,10 @@ fn validate_cloud_response(payload: &Value) -> Result<()> {
|
||||
bail!("GREE Cloud response error {code}: {message}");
|
||||
}
|
||||
}
|
||||
if matches!(payload.get("t").and_then(Value::as_str), Some("error" | "err")) {
|
||||
if matches!(
|
||||
payload.get("t").and_then(Value::as_str),
|
||||
Some("error" | "err")
|
||||
) {
|
||||
let message = payload
|
||||
.get("msg")
|
||||
.or_else(|| payload.get("error"))
|
||||
@@ -1398,8 +1619,14 @@ fn properties_from_payload(payload: &Value) -> Result<BTreeMap<String, Value>> {
|
||||
if payload.get("t").and_then(Value::as_str) != Some("dat") {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let cols = payload.get("cols").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no cols"))?;
|
||||
let dat = payload.get("dat").and_then(Value::as_array).ok_or_else(|| anyhow!("GREE Cloud dat payload has no dat"))?;
|
||||
let cols = payload
|
||||
.get("cols")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("GREE Cloud dat payload has no cols"))?;
|
||||
let dat = payload
|
||||
.get("dat")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("GREE Cloud dat payload has no dat"))?;
|
||||
if cols.len() != dat.len() {
|
||||
bail!("GREE Cloud dat payload column/value count mismatch");
|
||||
}
|
||||
@@ -1436,18 +1663,47 @@ fn apply_cloud_capability_snapshot(device: &mut Device, props: &BTreeMap<String,
|
||||
}
|
||||
|
||||
pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap<String, Value>) {
|
||||
if let Some(value) = props.get("Pow").and_then(value_i64) { device.power = value != 0; }
|
||||
if let Some(value) = props.get("Mod").and_then(value_i64) { if let Some(mode) = wire_to_mode(value) { device.mode = mode.into(); } }
|
||||
if let Some(value) = props.get("WdSpd").and_then(value_i64) { device.fan_speed = value.clamp(0, 5) as u8; }
|
||||
if let Some(value) = props.get("SwUpDn").and_then(value_i64) { device.swing_vertical = value != 0; }
|
||||
if let Some(value) = props.get("SwingLfRig").and_then(value_i64) { device.swing_horizontal = value != 0; }
|
||||
if let Some(value) = props.get("Quiet").and_then(value_i64) { device.quiet = value != 0; if value > 0 { device.quiet_wire_value = Some(value.clamp(1, 255) as u8); } }
|
||||
if let Some(value) = props.get("Tur").and_then(value_i64) { device.turbo = value != 0; }
|
||||
if let Some(value) = props.get("Lig").and_then(value_i64) { device.light = value != 0; }
|
||||
if let Some(value) = props.get("Air").and_then(value_i64) { device.air = value != 0; }
|
||||
if let Some(value) = props.get("Blo").and_then(value_i64) { device.xfan = value != 0; }
|
||||
if let Some(value) = props.get("Health").and_then(value_i64) { device.health = value != 0; }
|
||||
if let Some(value) = props.get("SwhSlp").and_then(value_i64) { device.sleep = value != 0; }
|
||||
if let Some(value) = props.get("Pow").and_then(value_i64) {
|
||||
device.power = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("Mod").and_then(value_i64) {
|
||||
if let Some(mode) = wire_to_mode(value) {
|
||||
device.mode = mode.into();
|
||||
}
|
||||
}
|
||||
if let Some(value) = props.get("WdSpd").and_then(value_i64) {
|
||||
device.fan_speed = value.clamp(0, 5) as u8;
|
||||
}
|
||||
if let Some(value) = props.get("SwUpDn").and_then(value_i64) {
|
||||
device.swing_vertical = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("SwingLfRig").and_then(value_i64) {
|
||||
device.swing_horizontal = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("Quiet").and_then(value_i64) {
|
||||
device.quiet = value != 0;
|
||||
if value > 0 {
|
||||
device.quiet_wire_value = Some(value.clamp(1, 255) as u8);
|
||||
}
|
||||
}
|
||||
if let Some(value) = props.get("Tur").and_then(value_i64) {
|
||||
device.turbo = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("Lig").and_then(value_i64) {
|
||||
device.light = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("Air").and_then(value_i64) {
|
||||
device.air = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("Blo").and_then(value_i64) {
|
||||
device.xfan = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("Health").and_then(value_i64) {
|
||||
device.health = value != 0;
|
||||
}
|
||||
if let Some(value) = props.get("SwhSlp").and_then(value_i64) {
|
||||
device.sleep = value != 0;
|
||||
}
|
||||
if let Some(raw) = props.get("TemSen").and_then(value_f64) {
|
||||
if let Some((temperature, offset)) = decode_indoor_temperature(raw) {
|
||||
device.current_temperature = Some(temperature);
|
||||
@@ -1459,41 +1715,74 @@ pub fn apply_cloud_properties(device: &mut Device, props: &BTreeMap<String, Valu
|
||||
.and_then(value_f64)
|
||||
.or_else(|| props.get("TemsSenOut").and_then(value_f64));
|
||||
if let Some(raw) = outdoor_raw {
|
||||
if let Some(temperature) = decode_outdoor_temperature(raw, device.temperature_sensor_offset) {
|
||||
if let Some(temperature) = decode_outdoor_temperature(raw, device.temperature_sensor_offset)
|
||||
{
|
||||
device.outdoor_temperature = Some(temperature);
|
||||
}
|
||||
}
|
||||
if let Some(half_enabled) = props.get("HalfTemEn").and_then(value_i64) {
|
||||
device.capabilities.temperature_step = if half_enabled == 1 { 0.5 } else { 1.0 };
|
||||
}
|
||||
if props.contains_key("SwUpDn") { device.capabilities.vertical_swing = true; }
|
||||
if props.contains_key("SwingLfRig") { device.capabilities.horizontal_swing = true; }
|
||||
if props.contains_key("SwUpDn") {
|
||||
device.capabilities.vertical_swing = true;
|
||||
}
|
||||
if props.contains_key("SwingLfRig") {
|
||||
device.capabilities.horizontal_swing = true;
|
||||
}
|
||||
if let Some(value) = props.get("SetDeciTem").and_then(value_f64) {
|
||||
device.target_temperature = value / 10.0;
|
||||
} else if let Some(value) = props.get("SetTem").and_then(value_f64) {
|
||||
let half = props.get("TemRec").and_then(value_f64).unwrap_or(0.0);
|
||||
device.target_temperature = value + if half > 0.0 { 0.5 } else { 0.0 };
|
||||
}
|
||||
if props.contains_key("Lig") { device.supports_light = Some(true); }
|
||||
if props.contains_key("Quiet") { device.supports_quiet = Some(true); }
|
||||
if props.contains_key("Tur") { device.supports_turbo = Some(true); }
|
||||
if props.contains_key("Air") { device.supports_air = Some(true); }
|
||||
if props.contains_key("Blo") { device.supports_xfan = Some(true); }
|
||||
if props.contains_key("Health") { device.supports_health = Some(true); }
|
||||
if props.contains_key("SwhSlp") { device.supports_sleep = Some(true); }
|
||||
if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") { device.supports_buzzer_control = Some(true); }
|
||||
if props.contains_key("ElcAll") { device.supports_energy_meter = Some(true); }
|
||||
if let Some(value) = props.get("ElcAll").and_then(value_f64) { device.total_energy_kwh = Some(value * 0.1); }
|
||||
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { device.compressor_frequency_hz = Some(value); }
|
||||
if props.contains_key("Lig") {
|
||||
device.supports_light = Some(true);
|
||||
}
|
||||
if props.contains_key("Quiet") {
|
||||
device.supports_quiet = Some(true);
|
||||
}
|
||||
if props.contains_key("Tur") {
|
||||
device.supports_turbo = Some(true);
|
||||
}
|
||||
if props.contains_key("Air") {
|
||||
device.supports_air = Some(true);
|
||||
}
|
||||
if props.contains_key("Blo") {
|
||||
device.supports_xfan = Some(true);
|
||||
}
|
||||
if props.contains_key("Health") {
|
||||
device.supports_health = Some(true);
|
||||
}
|
||||
if props.contains_key("SwhSlp") {
|
||||
device.supports_sleep = Some(true);
|
||||
}
|
||||
if props.contains_key("Buzzer_ON_OFF") || props.contains_key("BuzzerCtrl") {
|
||||
device.supports_buzzer_control = Some(true);
|
||||
}
|
||||
if props.contains_key("ElcAll") {
|
||||
device.supports_energy_meter = Some(true);
|
||||
}
|
||||
if let Some(value) = props.get("ElcAll").and_then(value_f64) {
|
||||
device.total_energy_kwh = Some(value * 0.1);
|
||||
}
|
||||
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) {
|
||||
device.compressor_frequency_hz = Some(value);
|
||||
}
|
||||
if let Some(hid) = props.get("hid").and_then(Value::as_str) {
|
||||
if device.firmware.is_empty() { device.firmware = firmware_from_hid(hid).unwrap_or_default(); }
|
||||
if device.firmware.is_empty() {
|
||||
device.firmware = firmware_from_hid(hid).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parsed_properties(props: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
|
||||
let mut parsed = BTreeMap::new();
|
||||
if let Some(value) = props.get("Pow").and_then(value_i64) { parsed.insert("power".into(), json!(value != 0)); }
|
||||
if let Some(value) = props.get("Mod").and_then(value_i64).and_then(wire_to_mode) { parsed.insert("mode".into(), json!(value)); }
|
||||
if let Some(value) = props.get("Pow").and_then(value_i64) {
|
||||
parsed.insert("power".into(), json!(value != 0));
|
||||
}
|
||||
if let Some(value) = props.get("Mod").and_then(value_i64).and_then(wire_to_mode) {
|
||||
parsed.insert("mode".into(), json!(value));
|
||||
}
|
||||
let mut sensor_offset = None;
|
||||
if let Some(raw) = props.get("TemSen").and_then(value_f64) {
|
||||
if let Some((temperature, offset)) = decode_indoor_temperature(raw) {
|
||||
@@ -1510,30 +1799,64 @@ fn parsed_properties(props: &BTreeMap<String, Value>) -> BTreeMap<String, Value>
|
||||
parsed.insert("outdoor_temperature_c".into(), json!(temperature));
|
||||
}
|
||||
}
|
||||
if let Some(value) = props.get("ElcAll").and_then(value_f64) { parsed.insert("total_energy_kwh".into(), json!(value * 0.1)); }
|
||||
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) { parsed.insert("compressor_frequency_hz".into(), json!(value)); }
|
||||
if let Some(value) = props.get("ElcAll").and_then(value_f64) {
|
||||
parsed.insert("total_energy_kwh".into(), json!(value * 0.1));
|
||||
}
|
||||
if let Some(value) = props.get("CompressorFqy").and_then(value_f64) {
|
||||
parsed.insert("compressor_frequency_hz".into(), json!(value));
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
fn merge_command(target: &mut DeviceCommand, source: &DeviceCommand) {
|
||||
if source.power.is_some() { target.power = source.power; }
|
||||
if source.mode.is_some() { target.mode = source.mode.clone(); }
|
||||
if source.target_temperature.is_some() { target.target_temperature = source.target_temperature; }
|
||||
if source.fan_speed.is_some() { target.fan_speed = source.fan_speed; }
|
||||
if source.swing_vertical.is_some() { target.swing_vertical = source.swing_vertical; }
|
||||
if source.swing_horizontal.is_some() { target.swing_horizontal = source.swing_horizontal; }
|
||||
if source.quiet.is_some() { target.quiet = source.quiet; }
|
||||
if source.turbo.is_some() { target.turbo = source.turbo; }
|
||||
if source.light.is_some() { target.light = source.light; }
|
||||
if source.air.is_some() { target.air = source.air; }
|
||||
if source.xfan.is_some() { target.xfan = source.xfan; }
|
||||
if source.health.is_some() { target.health = source.health; }
|
||||
if source.sleep.is_some() { target.sleep = source.sleep; }
|
||||
if source.power.is_some() {
|
||||
target.power = source.power;
|
||||
}
|
||||
if source.mode.is_some() {
|
||||
target.mode = source.mode.clone();
|
||||
}
|
||||
if source.target_temperature.is_some() {
|
||||
target.target_temperature = source.target_temperature;
|
||||
}
|
||||
if source.fan_speed.is_some() {
|
||||
target.fan_speed = source.fan_speed;
|
||||
}
|
||||
if source.swing_vertical.is_some() {
|
||||
target.swing_vertical = source.swing_vertical;
|
||||
}
|
||||
if source.swing_horizontal.is_some() {
|
||||
target.swing_horizontal = source.swing_horizontal;
|
||||
}
|
||||
if source.quiet.is_some() {
|
||||
target.quiet = source.quiet;
|
||||
}
|
||||
if source.turbo.is_some() {
|
||||
target.turbo = source.turbo;
|
||||
}
|
||||
if source.light.is_some() {
|
||||
target.light = source.light;
|
||||
}
|
||||
if source.air.is_some() {
|
||||
target.air = source.air;
|
||||
}
|
||||
if source.xfan.is_some() {
|
||||
target.xfan = source.xfan;
|
||||
}
|
||||
if source.health.is_some() {
|
||||
target.health = source.health;
|
||||
}
|
||||
if source.sleep.is_some() {
|
||||
target.sleep = source.sleep;
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_to_wire(mode: &str) -> Result<i64> {
|
||||
match mode {
|
||||
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
|
||||
"auto" => Ok(0),
|
||||
"cool" => Ok(1),
|
||||
"dry" => Ok(2),
|
||||
"fan" => Ok(3),
|
||||
"heat" => Ok(4),
|
||||
_ => bail!("unsupported GREE Cloud mode: {mode}"),
|
||||
}
|
||||
}
|
||||
@@ -1546,7 +1869,9 @@ fn decode_indoor_temperature(raw: f64) -> Option<(f64, bool)> {
|
||||
// 64 for 24 C. Older/local-compatible firmware can also report the real value.
|
||||
let offset = raw > 40.0;
|
||||
let temperature = if offset { raw - 40.0 } else { raw };
|
||||
(-40.0..=80.0).contains(&temperature).then_some((temperature, offset))
|
||||
(-40.0..=80.0)
|
||||
.contains(&temperature)
|
||||
.then_some((temperature, offset))
|
||||
}
|
||||
|
||||
fn decode_outdoor_temperature(raw: f64, sensor_offset: Option<bool>) -> Option<f64> {
|
||||
@@ -1562,14 +1887,29 @@ fn decode_outdoor_temperature(raw: f64, sensor_offset: Option<bool>) -> Option<f
|
||||
}
|
||||
|
||||
fn wire_to_mode(mode: i64) -> Option<&'static str> {
|
||||
match mode { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None }
|
||||
match mode {
|
||||
0 => Some("auto"),
|
||||
1 => Some("cool"),
|
||||
2 => Some("dry"),
|
||||
3 => Some("fan"),
|
||||
4 => Some("heat"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_i64(value: &Value) -> Option<i64> {
|
||||
value.as_i64().or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok())).or_else(|| value.as_f64().map(|v| v as i64)).or_else(|| value.as_str().and_then(|v| v.parse().ok()))
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_u64().and_then(|v| i64::try_from(v).ok()))
|
||||
.or_else(|| value.as_f64().map(|v| v as i64))
|
||||
.or_else(|| value.as_str().and_then(|v| v.parse().ok()))
|
||||
}
|
||||
fn value_f64(value: &Value) -> Option<f64> {
|
||||
value.as_f64().or_else(|| value.as_i64().map(|v| v as f64)).or_else(|| value.as_u64().map(|v| v as f64)).or_else(|| value.as_str().and_then(|v| v.parse().ok()))
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_i64().map(|v| v as f64))
|
||||
.or_else(|| value.as_u64().map(|v| v as f64))
|
||||
.or_else(|| value.as_str().and_then(|v| v.parse().ok()))
|
||||
}
|
||||
fn parent_mac_preserve_case(mac: &str) -> String {
|
||||
let compact = mac.trim().replace([':', '-'], "");
|
||||
@@ -1620,21 +1960,43 @@ fn is_legacy_cloud_firmware(firmware: &str) -> bool {
|
||||
value.starts_with("V1.") || value.starts_with("1.")
|
||||
}
|
||||
|
||||
fn normalize_mac(value: &str) -> String { value.trim().replace([':', '-'], "").to_ascii_uppercase() }
|
||||
fn cloud_id(device: &Device) -> String { normalize_mac(device.cloud_device_id.as_deref().unwrap_or(&device.mac)) }
|
||||
fn cloud_parent(device: &Device) -> String { device.cloud_parent_mac.clone().unwrap_or_else(|| parent_mac(&cloud_id(device))) }
|
||||
fn is_timeout_error(error: &anyhow::Error) -> bool { error.to_string().to_ascii_lowercase().contains("timed out") }
|
||||
fn normalize_mac(value: &str) -> String {
|
||||
value.trim().replace([':', '-'], "").to_ascii_uppercase()
|
||||
}
|
||||
fn cloud_id(device: &Device) -> String {
|
||||
normalize_mac(device.cloud_device_id.as_deref().unwrap_or(&device.mac))
|
||||
}
|
||||
fn cloud_parent(device: &Device) -> String {
|
||||
device
|
||||
.cloud_parent_mac
|
||||
.clone()
|
||||
.unwrap_or_else(|| parent_mac(&cloud_id(device)))
|
||||
}
|
||||
fn is_timeout_error(error: &anyhow::Error) -> bool {
|
||||
error.to_string().to_ascii_lowercase().contains("timed out")
|
||||
}
|
||||
fn sanitize_error(value: &str) -> String {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") { "GREE Cloud authentication/transport error".into() } else { value.chars().take(300).collect() }
|
||||
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
|
||||
"GREE Cloud authentication/transport error".into()
|
||||
} else {
|
||||
value.chars().take(300).collect()
|
||||
}
|
||||
}
|
||||
fn firmware_from_hid(hid: &str) -> Option<String> {
|
||||
let marker = hid.rfind('V')?;
|
||||
let value = hid.get(marker + 1..)?.strip_suffix(".bin").unwrap_or(&hid[marker + 1..]);
|
||||
let value = hid
|
||||
.get(marker + 1..)?
|
||||
.strip_suffix(".bin")
|
||||
.unwrap_or(&hid[marker + 1..]);
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
pub async fn cloud_reconnect_loop(provider: GreeCloudProvider, settings: Arc<RwLock<crate::models::RuntimeSettings>>, db: crate::db::Db) {
|
||||
pub async fn cloud_reconnect_loop(
|
||||
provider: GreeCloudProvider,
|
||||
settings: Arc<RwLock<crate::models::RuntimeSettings>>,
|
||||
db: crate::db::Db,
|
||||
) {
|
||||
let mut attempt = 0_u32;
|
||||
loop {
|
||||
let cloud = settings.read().await.gree_cloud.clone();
|
||||
@@ -1650,9 +2012,16 @@ pub async fn cloud_reconnect_loop(provider: GreeCloudProvider, settings: Arc<RwL
|
||||
}
|
||||
let devices = match db.list_devices() {
|
||||
Ok(items) => items,
|
||||
Err(err) => { tracing::warn!(error=?err, "cannot list devices for GREE Cloud reconnect"); sleep(Duration::from_secs(10)).await; continue; }
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, "cannot list devices for GREE Cloud reconnect");
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !devices.iter().any(|d| d.enabled && d.connection_type == ConnectionType::GreeCloud) {
|
||||
if !devices
|
||||
.iter()
|
||||
.any(|d| d.enabled && d.connection_type == ConnectionType::GreeCloud)
|
||||
{
|
||||
sleep(Duration::from_secs(15)).await;
|
||||
continue;
|
||||
}
|
||||
@@ -1738,15 +2107,12 @@ mod tests {
|
||||
assert!(mode_to_wire("unsupported").is_err());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn cloud_wire_parent_preserves_rest_mac_case_for_mqtt_topics() {
|
||||
let mut device = Device::simulated_default();
|
||||
device.connection_type = ConnectionType::GreeCloud;
|
||||
device.cloud_device_id = Some("9424B80C5DB0".into());
|
||||
let wire = HashMap::from([
|
||||
("9424B80C5DB0".to_string(), "9424b80c5db0".to_string()),
|
||||
]);
|
||||
let wire = HashMap::from([("9424B80C5DB0".to_string(), "9424b80c5db0".to_string())]);
|
||||
assert_eq!(cloud_wire_parent_from_map(&wire, &device), "9424b80c5db0");
|
||||
}
|
||||
|
||||
@@ -1778,7 +2144,6 @@ mod tests {
|
||||
assert!(!LEGACY_CLOUD_PROPERTIES.contains(&"CompressorFqy"));
|
||||
}
|
||||
|
||||
|
||||
#[tokio::test]
|
||||
async fn mqtt_payload_is_ignored_after_all_cloud_devices_are_removed() {
|
||||
let provider = GreeCloudProvider::new(reqwest::Client::new());
|
||||
@@ -1914,7 +2279,10 @@ mod tests {
|
||||
let sleep_sequence = provider
|
||||
.build_command_sequence(
|
||||
&device,
|
||||
&DeviceCommand { sleep: Some(true), ..Default::default() },
|
||||
&DeviceCommand {
|
||||
sleep: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
|
||||
+2
-4
@@ -222,10 +222,8 @@ impl AppState {
|
||||
let kind = kind.to_string();
|
||||
let message = message.to_string();
|
||||
handle.spawn(async move {
|
||||
crate::notifications::dispatch(
|
||||
state, event_id, level, kind, message, metadata,
|
||||
)
|
||||
.await;
|
||||
crate::notifications::dispatch(state, event_id, level, kind, message, metadata)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user