v0.14.1
This commit is contained in:
+8
-2
@@ -4,8 +4,8 @@ use crate::{
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport,
|
||||
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest,
|
||||
EnergySourcePreference, Flow, GreeSettings,
|
||||
ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup, DeviceGroupKind, DevicePatch, DiscoveryRequest,
|
||||
EnergyReading, EnergySourcePreference, Flow, GreeSettings,
|
||||
GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView,
|
||||
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
|
||||
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
|
||||
@@ -95,6 +95,11 @@ 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/:id",
|
||||
get(get_device_group).put(update_device_group).delete(delete_device_group),
|
||||
)
|
||||
.route("/api/zones", get(list_zones).post(create_zone))
|
||||
.route(
|
||||
"/api/zones/:id",
|
||||
@@ -344,6 +349,7 @@ pub fn router(state: AppState) -> Router {
|
||||
include!("api/auth.rs");
|
||||
include!("api/system.rs");
|
||||
include!("api/devices.rs");
|
||||
include!("api/device_groups.rs");
|
||||
include!("api/zones.rs");
|
||||
include!("api/groups.rs");
|
||||
include!("api/house.rs");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
struct ConfigurationIds<'a> {
|
||||
devices: std::collections::HashSet<&'a str>,
|
||||
zones: std::collections::HashSet<&'a str>,
|
||||
device_groups: std::collections::HashSet<&'a str>,
|
||||
schedules: std::collections::HashSet<&'a str>,
|
||||
automations: std::collections::HashSet<&'a str>,
|
||||
flows: std::collections::HashSet<&'a str>,
|
||||
@@ -26,7 +27,7 @@ async fn export_configuration(
|
||||
|
||||
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
if export.format_version != 3 {
|
||||
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.0".into()));
|
||||
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.14.1".into()));
|
||||
}
|
||||
if export.settings.control_strategy != "setpoint" {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -49,6 +50,7 @@ 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(),
|
||||
schedules: export
|
||||
.schedules
|
||||
.iter()
|
||||
@@ -63,11 +65,13 @@ fn collect_configuration_ids(
|
||||
};
|
||||
let duplicate_or_empty = ids.devices.len() != export.devices.len()
|
||||
|| ids.zones.len() != export.zones.len()
|
||||
|| ids.device_groups.len() != export.device_groups.len()
|
||||
|| ids.schedules.len() != export.schedules.len()
|
||||
|| ids.automations.len() != export.automations.len()
|
||||
|| ids.flows.len() != export.flows.len()
|
||||
|| ids.devices.contains("")
|
||||
|| ids.zones.contains("")
|
||||
|| ids.device_groups.contains("")
|
||||
|| ids.schedules.contains("")
|
||||
|| ids.automations.contains("")
|
||||
|| ids.flows.contains("");
|
||||
@@ -154,6 +158,71 @@ fn validate_configuration_devices_and_zones(
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut installation_devices = std::collections::HashSet::new();
|
||||
for group in &export.device_groups {
|
||||
if group.name.trim().is_empty() || group.device_ids.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains an empty split/multisplit installation".into(),
|
||||
));
|
||||
}
|
||||
if group.kind == DeviceGroupKind::Split && group.device_ids.len() != 1 {
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains a split installation with more than one unit".into(),
|
||||
));
|
||||
}
|
||||
for device_id in &group.device_ids {
|
||||
if !ids.devices.contains(device_id.as_str()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains an installation referencing a missing device".into(),
|
||||
));
|
||||
}
|
||||
if !installation_devices.insert(device_id.as_str()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"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)) {
|
||||
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() {
|
||||
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()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains a Home Assistant installation without an energy entity".into(),
|
||||
));
|
||||
}
|
||||
if let Some(energy_device_id) = group.energy_device_id.as_deref() {
|
||||
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 {
|
||||
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_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"))
|
||||
{
|
||||
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)) {
|
||||
return Err(AppError::BadRequest(
|
||||
"import contains an installation outdoor-temperature source outside the installation".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceGroupInput {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
kind: DeviceGroupKind,
|
||||
#[serde(default)]
|
||||
device_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
energy_source: EnergySourcePreference,
|
||||
#[serde(default)]
|
||||
energy_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_entity_id: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_unit: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_device_class: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_state_class: Option<String>,
|
||||
#[serde(default)]
|
||||
outdoor_temperature_device_id: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_optional(value: Option<String>) -> Option<String> {
|
||||
value.map(|item| item.trim().to_string()).filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn validate_device_group_input(
|
||||
state: &AppState,
|
||||
input: &DeviceGroupInput,
|
||||
editing_id: Option<&str>,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
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<_>>();
|
||||
device_ids.sort();
|
||||
device_ids.dedup();
|
||||
if device_ids.is_empty() {
|
||||
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()));
|
||||
}
|
||||
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}")));
|
||||
}
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
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()));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
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 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()));
|
||||
}
|
||||
}
|
||||
Ok(device_ids)
|
||||
}
|
||||
|
||||
fn device_group_from_input(id: String, existing: Option<DeviceGroup>, input: DeviceGroupInput, device_ids: Vec<String>) -> DeviceGroup {
|
||||
let now = Utc::now();
|
||||
DeviceGroup {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
kind: input.kind,
|
||||
device_ids,
|
||||
energy_source: input.energy_source,
|
||||
energy_device_id: normalize_optional(input.energy_device_id),
|
||||
ha_energy_entity_id: normalize_optional(input.ha_energy_entity_id),
|
||||
ha_energy_unit: normalize_optional(input.ha_energy_unit),
|
||||
ha_energy_device_class: normalize_optional(input.ha_energy_device_class),
|
||||
ha_energy_state_class: normalize_optional(input.ha_energy_state_class),
|
||||
outdoor_temperature_device_id: normalize_optional(input.outdoor_temperature_device_id),
|
||||
created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
state.db.save_device_group(&group)?;
|
||||
state.broadcast("device_group.created", serde_json::to_value(&group)?);
|
||||
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> {
|
||||
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 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)?;
|
||||
state.broadcast("device_group.updated", serde_json::to_value(&group)?);
|
||||
Ok(Json(group))
|
||||
}
|
||||
|
||||
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}")));
|
||||
}
|
||||
state.broadcast("device_group.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
+156
-80
@@ -491,11 +491,16 @@ async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppE
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EnergyHistoryQuery {
|
||||
device_id: String,
|
||||
/// Backward-compatible single-device selector.
|
||||
device_id: Option<String>,
|
||||
/// Device id or `group:<installation-id>`.
|
||||
target_id: Option<String>,
|
||||
interval: Option<String>,
|
||||
source: Option<String>,
|
||||
days: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
/// none, previous_day, previous_period or previous_year.
|
||||
compare: Option<String>,
|
||||
}
|
||||
|
||||
async fn energy_history(
|
||||
@@ -505,10 +510,12 @@ async fn energy_history(
|
||||
use chrono::{Datelike, NaiveDate, Timelike, Weekday};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&query.device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {}", query.device_id)))?;
|
||||
let target_id = query
|
||||
.target_id
|
||||
.as_deref()
|
||||
.or(query.device_id.as_deref())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("energy target_id is required".into()))?;
|
||||
let interval_name = query.interval.as_deref().unwrap_or("daily");
|
||||
if !matches!(interval_name, "hourly" | "daily" | "weekly" | "monthly") {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -516,8 +523,86 @@ async fn energy_history(
|
||||
));
|
||||
}
|
||||
let days = query.days.unwrap_or(31).clamp(1, 3650);
|
||||
let compare_name = query.compare.as_deref().unwrap_or("none");
|
||||
let compare_shift_days = match compare_name {
|
||||
"none" => None,
|
||||
"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())),
|
||||
};
|
||||
|
||||
let requested_source = query.source.as_deref().unwrap_or("auto");
|
||||
if !matches!(requested_source, "auto" | "gree_cloud" | "home_assistant") {
|
||||
return Err(AppError::BadRequest(
|
||||
"energy source must be auto, gree_cloud or home_assistant".into(),
|
||||
));
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
} 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);
|
||||
let comparison_since = compare_shift_days.map(|shift| since - ChronoDuration::days(shift));
|
||||
let month_start_for_load = chrono::DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
|
||||
.expect("valid current month")
|
||||
@@ -537,7 +622,10 @@ async fn energy_history(
|
||||
.expect("valid midnight"),
|
||||
Utc,
|
||||
);
|
||||
let load_since = since.min(previous_month_start_for_load);
|
||||
let mut load_since = since.min(previous_month_start_for_load);
|
||||
if let Some(compare_since) = comparison_since {
|
||||
load_since = load_since.min(compare_since);
|
||||
}
|
||||
let limit = query.limit.unwrap_or(100_000).clamp(1, 200_000);
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = now - ChronoDuration::days(influx.history_threshold_days as i64);
|
||||
@@ -547,7 +635,7 @@ async fn energy_history(
|
||||
match influxdb::query_energy(
|
||||
&state.http,
|
||||
&influx,
|
||||
&device.id,
|
||||
&storage_id,
|
||||
None,
|
||||
load_since,
|
||||
cutoff,
|
||||
@@ -557,7 +645,7 @@ async fn energy_history(
|
||||
.await
|
||||
{
|
||||
Ok(mut archived) => {
|
||||
archived.extend(state.db.list_energy_readings(&device.id, cutoff, limit)?);
|
||||
archived.extend(state.db.list_energy_readings(&storage_id, cutoff, limit)?);
|
||||
storage = "influx+sqlite".into();
|
||||
archived
|
||||
}
|
||||
@@ -568,38 +656,28 @@ async fn energy_history(
|
||||
"warn",
|
||||
"influx.query_error",
|
||||
"InfluxDB energy history query failed",
|
||||
json!({"device_id": device.id, "error": err.to_string()}),
|
||||
json!({"target_id": public_target_id, "storage_id": storage_id, "error": err.to_string()}),
|
||||
);
|
||||
state.db.list_energy_readings(&device.id, load_since, limit)?
|
||||
state.db.list_energy_readings(&storage_id, load_since, limit)?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.db.list_energy_readings(&device.id, load_since, limit)?
|
||||
state.db.list_energy_readings(&storage_id, load_since, limit)?
|
||||
};
|
||||
samples.sort_by_key(|row| row.timestamp);
|
||||
|
||||
let requested_source = query.source.as_deref().unwrap_or("auto");
|
||||
let selected_source = match requested_source {
|
||||
"gree_cloud" => Some("gree_cloud"),
|
||||
"home_assistant" => Some("home_assistant"),
|
||||
"auto" => match device.energy_source {
|
||||
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
|
||||
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
|
||||
EnergySourcePreference::Auto => {
|
||||
if samples.iter().any(|row| row.source == "gree_cloud") {
|
||||
Some("gree_cloud")
|
||||
} else if samples.iter().any(|row| row.source == "home_assistant") {
|
||||
Some("home_assistant")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"energy source must be auto, gree_cloud or home_assistant".into(),
|
||||
))
|
||||
let selected_source = if let Some(source) = preferred_source {
|
||||
Some(source)
|
||||
} else if requested_source == "auto" {
|
||||
if samples.iter().any(|row| row.source == "gree_cloud") {
|
||||
Some("gree_cloud")
|
||||
} else if samples.iter().any(|row| row.source == "home_assistant") {
|
||||
Some("home_assistant")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(requested_source)
|
||||
};
|
||||
if let Some(source) = selected_source {
|
||||
samples.retain(|row| row.source == source);
|
||||
@@ -613,82 +691,80 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
let period_samples = samples
|
||||
.iter()
|
||||
.filter(|row| row.timestamp >= since)
|
||||
.collect::<Vec<_>>();
|
||||
let mut buckets: BTreeMap<chrono::DateTime<Utc>, f64> = BTreeMap::new();
|
||||
for sample in &period_samples {
|
||||
*buckets
|
||||
.entry(bucket_start(sample.timestamp, interval_name))
|
||||
.or_default() += sample.consumption_kwh.max(0.0);
|
||||
fn bucket_rows<'a>(
|
||||
rows: impl Iterator<Item = &'a EnergyReading>,
|
||||
interval_name: &str,
|
||||
shift_days: i64,
|
||||
) -> Vec<Value> {
|
||||
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.into_iter().map(|(start, consumption_kwh)| json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})).collect()
|
||||
}
|
||||
let buckets = buckets
|
||||
.into_iter()
|
||||
.map(|(start, consumption_kwh)| {
|
||||
json!({"start": start, "consumption_kwh": consumption_kwh.max(0.0)})
|
||||
})
|
||||
.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 total: f64 = rows.iter().map(|row| row.consumption_kwh.max(0.0)).sum();
|
||||
Some(json!({
|
||||
"kind": compare_name,
|
||||
"shift_days": shift,
|
||||
"period_start": compare_start,
|
||||
"period_end": compare_end,
|
||||
"period_total": total,
|
||||
"buckets": bucket_rows(rows.iter().copied(), interval_name, shift),
|
||||
}))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
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!({
|
||||
"device_id": device.id,
|
||||
"target_id": public_target_id,
|
||||
"target_type": target_type,
|
||||
"target_name": target_name,
|
||||
"member_device_ids": member_device_ids,
|
||||
"device_id": if target_type == "device" { Some(public_target_id.clone()) } else { None::<String> },
|
||||
"source": selected_source.unwrap_or("none"),
|
||||
"configured_source": device.energy_source,
|
||||
"configured_source": configured_source,
|
||||
"interval": interval_name,
|
||||
"unit": "kWh",
|
||||
"period_days": days,
|
||||
"storage": storage,
|
||||
"storage_warning": storage_warning,
|
||||
"buckets": buckets,
|
||||
"comparison": comparison,
|
||||
"summary": {
|
||||
"today": sum_range(today_start, now + ChronoDuration::seconds(1)),
|
||||
"yesterday": sum_range(yesterday_start, today_start),
|
||||
|
||||
@@ -25,6 +25,7 @@ struct BootstrapResponse {
|
||||
devices: Vec<Device>,
|
||||
zones: Vec<Zone>,
|
||||
groups: Vec<ClimateGroup>,
|
||||
device_groups: Vec<DeviceGroup>,
|
||||
schedules: Vec<Schedule>,
|
||||
automations: Vec<Automation>,
|
||||
flows: Vec<Flow>,
|
||||
@@ -65,6 +66,7 @@ async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError
|
||||
devices,
|
||||
zones: state.db.list_zones()?,
|
||||
groups: state.db.list_groups()?,
|
||||
device_groups: state.db.list_device_groups()?,
|
||||
schedules: state.db.list_schedules()?,
|
||||
automations: state.db.list_automations()?,
|
||||
flows: state.db.list_flows()?,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
models::{
|
||||
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, EventLog, Flow,
|
||||
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device, DeviceGroup, EnergySourcePreference, EventLog, Flow,
|
||||
EnergyReading, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
|
||||
},
|
||||
queries,
|
||||
|
||||
@@ -48,6 +48,32 @@ impl Db {
|
||||
pub fn delete_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("groups", id)
|
||||
}
|
||||
|
||||
pub fn save_device_group(&self, group: &DeviceGroup) -> Result<()> {
|
||||
let payload = Self::to_json(group)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_DEVICE_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_device_groups(&self) -> Result<Vec<DeviceGroup>> {
|
||||
self.list_payloads(queries::LIST_DEVICE_GROUPS)
|
||||
}
|
||||
|
||||
pub fn get_device_group(&self, id: &str) -> Result<Option<DeviceGroup>> {
|
||||
self.get_payload(queries::GET_DEVICE_GROUP, id)
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
pub fn delete_device_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("device_groups", id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Db {
|
||||
|
||||
@@ -7,6 +7,7 @@ impl Db {
|
||||
devices: self.list_devices()?,
|
||||
zones: self.list_zones()?,
|
||||
groups: self.list_groups()?,
|
||||
device_groups: self.list_device_groups()?,
|
||||
schedules: self.list_schedules()?,
|
||||
automations: self.list_automations()?,
|
||||
flows: self.list_flows()?,
|
||||
@@ -50,6 +51,13 @@ impl Db {
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
for device_group in &export.device_groups {
|
||||
let payload = Self::to_json(device_group)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_DEVICE_GROUP,
|
||||
params![device_group.id, payload, device_group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
for schedule in &export.schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
|
||||
@@ -89,6 +89,23 @@ 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; }
|
||||
group.device_ids.retain(|device_id| device_id != id);
|
||||
if group.energy_device_id.as_deref() == Some(id) {
|
||||
group.energy_device_id = None;
|
||||
if group.energy_source == EnergySourcePreference::GreeCloud {
|
||||
group.energy_source = EnergySourcePreference::Auto;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
group.updated_at = Utc::now();
|
||||
self.save_device_group(&group)?;
|
||||
}
|
||||
}
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||
|
||||
@@ -88,6 +88,7 @@ impl Db {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
"groups" => queries::DELETE_GROUP,
|
||||
"device_groups" => queries::DELETE_DEVICE_GROUP,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading,
|
||||
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType, ControlPlan, ControlPlanEvent, Device, EnergyReading, EnergySourcePreference,
|
||||
DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings,
|
||||
Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading,
|
||||
},
|
||||
|
||||
+16
-5
@@ -71,10 +71,7 @@ fn queue_influx_energy(state: &AppState, reading: EnergyReading) {
|
||||
});
|
||||
}
|
||||
|
||||
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_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
|
||||
@@ -92,10 +89,15 @@ async fn sample_home_assistant_energy_device(state: &AppState, device: &Device)
|
||||
if !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh") {
|
||||
return Err(AppError::BadRequest("Home Assistant energy sensor must use Wh or kWh".into()));
|
||||
}
|
||||
let _ = record_cumulative_energy_sample(state, &device.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(()); };
|
||||
sample_home_assistant_energy_target(state, &device.id, entity_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn home_assistant_energy_loop(state: AppState) {
|
||||
sleep(Duration::from_secs(10)).await;
|
||||
loop {
|
||||
@@ -108,6 +110,15 @@ pub(crate) async fn home_assistant_energy_loop(state: AppState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
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; };
|
||||
let target_id = format!("group:{}", group.id);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
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
|
||||
}
|
||||
|
||||
fn record_zone_history(
|
||||
state: &AppState,
|
||||
zone: &Zone,
|
||||
@@ -28,7 +34,9 @@ fn record_zone_history(
|
||||
.or(zone.manual_setpoint)
|
||||
.or(Some(zone.setpoint)),
|
||||
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
|
||||
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
|
||||
outdoor_temperature: installation_outdoor_temperature(state, &device.id)
|
||||
.or(outdoor_temperature)
|
||||
.or(device.outdoor_temperature),
|
||||
power: device.power,
|
||||
mode: if zone.effective_mode.is_empty() {
|
||||
device.mode.clone()
|
||||
|
||||
@@ -279,7 +279,7 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
device_id: device.id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
indoor_temperature: device.current_temperature,
|
||||
outdoor_temperature: 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() },
|
||||
|
||||
@@ -7,6 +7,8 @@ pub struct ConfigurationExport {
|
||||
pub zones: Vec<Zone>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<ClimateGroup>,
|
||||
#[serde(default)]
|
||||
pub device_groups: Vec<DeviceGroup>,
|
||||
pub schedules: Vec<Schedule>,
|
||||
pub automations: Vec<Automation>,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -72,6 +72,42 @@ pub enum EnergySourcePreference {
|
||||
HomeAssistant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeviceGroupKind {
|
||||
#[default]
|
||||
Split,
|
||||
Multisplit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub kind: DeviceGroupKind,
|
||||
#[serde(default)]
|
||||
pub device_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub energy_source: EnergySourcePreference,
|
||||
/// Member device whose cumulative GREE Cloud meter represents the whole installation.
|
||||
#[serde(default)]
|
||||
pub energy_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ha_energy_entity_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ha_energy_unit: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ha_energy_device_class: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ha_energy_state_class: Option<String>,
|
||||
/// Member device whose outdoor sensor is shared by every unit in this installation.
|
||||
#[serde(default)]
|
||||
pub outdoor_temperature_device_id: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
pub id: String,
|
||||
|
||||
+57
-3
@@ -267,7 +267,7 @@ impl GreeCloudProvider {
|
||||
}
|
||||
|
||||
pub async fn unregister_device(&self, device_id: &str) {
|
||||
let cloud_ids = {
|
||||
let (cloud_ids, no_registered_devices) = {
|
||||
let mut registered = self.inner.registered.write().await;
|
||||
let ids = registered
|
||||
.iter()
|
||||
@@ -275,7 +275,8 @@ impl GreeCloudProvider {
|
||||
.map(|(cloud_id, _)| cloud_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
registered.retain(|_, item| item.device_id != device_id);
|
||||
ids
|
||||
let empty = registered.is_empty();
|
||||
(ids, empty)
|
||||
};
|
||||
let mut pending = self.inner.pending.lock().await;
|
||||
for cloud_id in cloud_ids {
|
||||
@@ -284,6 +285,18 @@ impl GreeCloudProvider {
|
||||
drop(pending);
|
||||
self.inner.command_locks.lock().await.remove(device_id);
|
||||
self.inner.diagnostics.write().await.remove(device_id);
|
||||
|
||||
// The broker can continue publishing retained/status traffic for topics from the
|
||||
// previous subscription until the MQTT session is closed. Once the last registered
|
||||
// Cloud device is removed there is nothing useful to receive, so close the session
|
||||
// immediately instead of leaving a stale subscription alive until process restart.
|
||||
if no_registered_devices {
|
||||
let session = self.inner.session.write().await.take();
|
||||
if let Some(session) = session {
|
||||
session.mqtt.disconnect().await;
|
||||
tracing::info!("GREE Cloud MQTT disconnected; no registered devices remain");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn poll(
|
||||
@@ -1115,6 +1128,14 @@ impl GreeCloudProvider {
|
||||
"topic": topic,
|
||||
"bytes": payload.len(),
|
||||
}));
|
||||
// A broker message can race with device deletion. If the last Cloud device has
|
||||
// already been unregistered, any in-flight message belongs to a stale subscription
|
||||
// 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");
|
||||
return Ok(());
|
||||
}
|
||||
if topic.starts_with("connect/") {
|
||||
let parent = topic.split('/').nth(1).unwrap_or_default().to_string();
|
||||
self.emit_cloud_mqtt(json!({
|
||||
@@ -1143,7 +1164,6 @@ impl GreeCloudProvider {
|
||||
"cipher": if envelope.tag.is_some() { 2 } else { 1 },
|
||||
}));
|
||||
let parent_from_topic = topic.split('/').nth(1).unwrap_or_default().to_string();
|
||||
let registered = self.inner.registered.read().await.clone();
|
||||
// Match the reference client primarily by subscribed parent topic. Some GREE
|
||||
// responses use a parent/variant tcid that is not byte-for-byte equal to the
|
||||
// discovery child id; restricting candidates to tcid caused valid frames to be
|
||||
@@ -1752,6 +1772,40 @@ 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());
|
||||
let result = provider
|
||||
.handle_raw_message("status/stale-parent/device", b"not-json-anymore")
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mqtt_payload_for_unknown_parent_still_errors_when_devices_are_registered() {
|
||||
let provider = GreeCloudProvider::new(reqwest::Client::new());
|
||||
provider.inner.registered.write().await.insert(
|
||||
"AABBCCDDEEFF".into(),
|
||||
RegisteredDevice {
|
||||
device_id: "cloud-test".into(),
|
||||
key: "0123456789abcdef".into(),
|
||||
parent_mac: "AABBCCDDEE".into(),
|
||||
cipher_version: 1,
|
||||
},
|
||||
);
|
||||
let err = provider
|
||||
.handle_raw_message(
|
||||
"status/1122334455/device",
|
||||
br#"{"pack":"x","tcid":"112233445566"}"#,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("does not match a registered device"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_dispatcher_selects_transport_from_connection_type() {
|
||||
let local = GreeClient::new(
|
||||
|
||||
@@ -47,6 +47,17 @@ pub const LIST_GROUPS: &str =
|
||||
pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1";
|
||||
pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1";
|
||||
|
||||
pub const UPSERT_DEVICE_GROUP: &str = r#"
|
||||
INSERT INTO device_groups(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_DEVICE_GROUPS: &str =
|
||||
"SELECT payload FROM device_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_DEVICE_GROUP: &str = "SELECT payload FROM device_groups WHERE id=?1";
|
||||
pub const DELETE_DEVICE_GROUP: &str = "DELETE FROM device_groups WHERE id=?1";
|
||||
|
||||
pub const UPSERT_SCHEDULE: &str = r#"
|
||||
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
|
||||
@@ -39,6 +39,7 @@ DELETE FROM schedules;
|
||||
DELETE FROM automations;
|
||||
DELETE FROM flows;
|
||||
DELETE FROM climate_groups;
|
||||
DELETE FROM device_groups;
|
||||
DELETE FROM zones;
|
||||
DELETE FROM devices;
|
||||
"#;
|
||||
|
||||
@@ -36,6 +36,12 @@ CREATE TABLE IF NOT EXISTS climate_groups (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
zone_id TEXT NOT NULL,
|
||||
@@ -155,5 +161,7 @@ INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (6, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (7, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (8, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user