v0.5.0
This commit is contained in:
+224
-11
@@ -1,4 +1,4 @@
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||
@@ -21,7 +21,8 @@ use crate::{
|
||||
engine,
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
influxdb,
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
@@ -56,8 +57,12 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
|
||||
.route("/api/readings", get(readings))
|
||||
.route("/api/history", get(history))
|
||||
.route("/api/control-plan", get(control_plan))
|
||||
.route("/api/events", get(events))
|
||||
.route("/api/settings", get(get_settings).put(update_settings))
|
||||
.route("/api/settings/export", get(export_settings))
|
||||
.route("/api/settings/import", post(import_settings))
|
||||
.route("/api/debug", get(get_debug).put(update_debug))
|
||||
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
|
||||
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
|
||||
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
|
||||
@@ -66,6 +71,8 @@ pub fn router(state: AppState) -> Router {
|
||||
let home_assistant_api = Router::new()
|
||||
.route("/api/integrations/home-assistant/devices", get(list_devices))
|
||||
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
|
||||
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
|
||||
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
||||
|
||||
Router::new()
|
||||
@@ -86,9 +93,27 @@ pub fn router(state: AppState) -> Router {
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn debug_api_requests(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
if !state.settings.read().await.debug.overlay_enabled {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let method = request.method().clone();
|
||||
let path = request.uri().path().to_string();
|
||||
let started = Instant::now();
|
||||
let response = next.run(request).await;
|
||||
state.broadcast("api.request", json!({
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"status": response.status().as_u16(),
|
||||
"duration_ms": started.elapsed().as_millis(),
|
||||
}));
|
||||
response
|
||||
}
|
||||
|
||||
async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> {
|
||||
let expected = state.config.app_token.trim();
|
||||
if expected.is_empty() {
|
||||
@@ -735,7 +760,7 @@ async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReadingsQuery { device_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
|
||||
async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQuery>) -> Result<Json<Value>, AppError> {
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
|
||||
let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?;
|
||||
Ok(Json(json!({"readings": values})))
|
||||
}
|
||||
@@ -755,7 +780,10 @@ fn history_bucket_seconds(hours: i64) -> i64 {
|
||||
1..=6 => 30,
|
||||
7..=24 => 120,
|
||||
25..=168 => 600,
|
||||
_ => 1800,
|
||||
169..=720 => 1800,
|
||||
721..=2160 => 7200,
|
||||
2161..=8760 => 21600,
|
||||
_ => 86400,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,8 +886,110 @@ fn sensor_history_with_fallback(
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
async fn combined_device_history(
|
||||
state: &AppState,
|
||||
device_id: Option<&str>,
|
||||
since: chrono::DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<Reading>, String, Option<String>), AppError> {
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
||||
if !influx.enabled || since >= cutoff {
|
||||
return Ok((state.db.list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None));
|
||||
}
|
||||
let mut warning = None;
|
||||
let mut values = match influxdb::query_devices(&state.http, &influx, device_id, since, cutoff, bucket_seconds, limit).await {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
warning = Some(err.to_string());
|
||||
state.log("warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()}));
|
||||
state.db.list_device_history(device_id, since, bucket_seconds, limit)?
|
||||
}
|
||||
};
|
||||
if warning.is_none() {
|
||||
values.extend(state.db.list_device_history(device_id, cutoff, bucket_seconds, limit)?);
|
||||
}
|
||||
values.sort_by_key(|row| row.timestamp);
|
||||
trim_history(&mut values, limit);
|
||||
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
|
||||
Ok((values, source.into(), warning))
|
||||
}
|
||||
|
||||
async fn combined_zone_history(
|
||||
state: &AppState,
|
||||
zone_id: Option<&str>,
|
||||
since: chrono::DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<ZoneReading>, String, Option<String>), AppError> {
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
||||
if !influx.enabled || since >= cutoff {
|
||||
return Ok((zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None));
|
||||
}
|
||||
let mut warning = None;
|
||||
let mut values = match influxdb::query_zones(&state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit).await {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
warning = Some(err.to_string());
|
||||
state.log("warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()}));
|
||||
zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?
|
||||
}
|
||||
};
|
||||
if warning.is_none() {
|
||||
values.extend(zone_history_with_fallback(state, zone_id, cutoff, bucket_seconds, limit)?);
|
||||
}
|
||||
values.sort_by_key(|row| row.timestamp);
|
||||
trim_history(&mut values, limit);
|
||||
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
|
||||
Ok((values, source.into(), warning))
|
||||
}
|
||||
|
||||
async fn combined_sensor_history(
|
||||
state: &AppState,
|
||||
entity_id: Option<&str>,
|
||||
since: chrono::DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
outdoor_entity: &str,
|
||||
) -> Result<(Vec<HaReading>, String, Option<String>), AppError> {
|
||||
let influx = state.settings.read().await.influxdb.clone();
|
||||
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
|
||||
let local = |start| -> Result<Vec<HaReading>, AppError> {
|
||||
if entity_id.is_some() { Ok(state.db.list_ha_history(entity_id, start, bucket_seconds, limit)?) }
|
||||
else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) }
|
||||
};
|
||||
if !influx.enabled || since >= cutoff {
|
||||
return Ok((local(since)?, "sqlite".into(), None));
|
||||
}
|
||||
let mut warning = None;
|
||||
let mut values = match influxdb::query_ha(&state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit).await {
|
||||
Ok(rows) => rows,
|
||||
Err(err) => {
|
||||
warning = Some(err.to_string());
|
||||
state.log("warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()}));
|
||||
local(since)?
|
||||
}
|
||||
};
|
||||
if warning.is_none() {
|
||||
values.extend(local(cutoff)?);
|
||||
}
|
||||
values.sort_by_key(|row| row.timestamp);
|
||||
trim_history(&mut values, limit);
|
||||
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
|
||||
Ok((values, source.into(), warning))
|
||||
}
|
||||
|
||||
fn trim_history<T>(values: &mut Vec<T>, limit: u32) {
|
||||
if values.len() > limit as usize {
|
||||
let keep_from = values.len() - limit as usize;
|
||||
values.drain(0..keep_from);
|
||||
}
|
||||
}
|
||||
|
||||
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
|
||||
let since = Utc::now() - ChronoDuration::hours(hours);
|
||||
let bucket_seconds = history_bucket_seconds(hours);
|
||||
let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000);
|
||||
@@ -870,27 +1000,31 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
|
||||
match scope {
|
||||
"devices" => {
|
||||
let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
let readings = state.db.list_device_history(device_id, since.clone(), bucket_seconds, limit)?;
|
||||
let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?;
|
||||
Ok(Json(json!({
|
||||
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"storage": storage, "storage_warning": warning,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
"sensors" => {
|
||||
let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
let readings = if entity_id.is_some() { state.db.list_ha_history(entity_id, since.clone(), bucket_seconds, limit)? } else { sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)? };
|
||||
let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?;
|
||||
Ok(Json(json!({
|
||||
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"storage": storage, "storage_warning": warning,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
"overview" => {
|
||||
let zones = zone_history_with_fallback(&state, None, since.clone(), bucket_seconds, limit)?;
|
||||
let devices = state.db.list_device_history(None, since.clone(), bucket_seconds, limit)?;
|
||||
let sensors = sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)?;
|
||||
let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).await?;
|
||||
let (devices, device_storage, device_warning) = combined_device_history(&state, None, since, bucket_seconds, limit).await?;
|
||||
let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(&state, None, since, bucket_seconds, limit, &outdoor_entity).await?;
|
||||
Ok(Json(json!({
|
||||
"scope": "overview", "bucket_seconds": bucket_seconds,
|
||||
"zones": zones, "devices": devices, "sensors": sensors,
|
||||
"storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage},
|
||||
"storage_warning": [zone_warning, device_warning, sensor_warning].into_iter().flatten().collect::<Vec<_>>(),
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
@@ -901,9 +1035,10 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
|
||||
return Err(AppError::NotFound(format!("zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
let readings = zone_history_with_fallback(&state, zone_id, since.clone(), bucket_seconds, limit)?;
|
||||
let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?;
|
||||
Ok(Json(json!({
|
||||
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
|
||||
"storage": storage, "storage_warning": warning,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
@@ -911,6 +1046,10 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
|
||||
}
|
||||
}
|
||||
|
||||
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsQuery { limit: Option<u32> }
|
||||
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
|
||||
@@ -936,17 +1075,73 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
}
|
||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
|
||||
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
|
||||
influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
if !input.home_assistant.url.trim().is_empty() {
|
||||
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
|
||||
}
|
||||
state.db.save_runtime_settings(&input)?;
|
||||
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = input.clone();
|
||||
state.log("info", "settings.updated", "Settings updated", json!({}));
|
||||
state.broadcast("settings.updated", public_settings(&input));
|
||||
Ok(Json(public_settings(&input)))
|
||||
}
|
||||
|
||||
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
Ok(Json(state.db.export_configuration(settings)?))
|
||||
}
|
||||
|
||||
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
|
||||
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
|
||||
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
|
||||
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
|
||||
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
|
||||
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
|
||||
}
|
||||
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
|
||||
}
|
||||
if export.automations.iter().any(|item| !devices.contains(item.action_device_id.as_str())) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
}
|
||||
if export.automations.iter().any(|item| item.trigger_device_id.as_deref().is_some_and(|id| !devices.contains(id))) {
|
||||
return Err(AppError::BadRequest("import contains an automation trigger referencing a missing device".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
|
||||
validate_configuration_export(&export)?;
|
||||
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
|
||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
state.db.replace_configuration(&export)?;
|
||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = export.settings.clone();
|
||||
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
|
||||
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
|
||||
Json(state.settings.read().await.debug.clone())
|
||||
}
|
||||
|
||||
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.debug = input.clone();
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
|
||||
state.broadcast("debug.settings", serde_json::to_value(&input)?);
|
||||
Ok(Json(input))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateAccessTokenRequest {
|
||||
name: Option<String>,
|
||||
@@ -1015,6 +1210,24 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"house_mode": settings.house_mode,
|
||||
"control_strategy": settings.control_strategy,
|
||||
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
|
||||
"history_retention_days": settings.history_retention_days,
|
||||
"history_compaction_enabled": settings.history_compaction_enabled,
|
||||
"suppress_device_beep": settings.suppress_device_beep,
|
||||
"debug": settings.debug,
|
||||
"influxdb": {
|
||||
"enabled": settings.influxdb.enabled,
|
||||
"version": settings.influxdb.version,
|
||||
"url": settings.influxdb.url,
|
||||
"database": settings.influxdb.database,
|
||||
"username": settings.influxdb.username,
|
||||
"password": "",
|
||||
"password_configured": !settings.influxdb.password.trim().is_empty(),
|
||||
"org": settings.influxdb.org,
|
||||
"bucket": settings.influxdb.bucket,
|
||||
"token": "",
|
||||
"token_configured": !settings.influxdb.token.trim().is_empty(),
|
||||
"history_threshold_days": settings.influxdb.history_threshold_days,
|
||||
},
|
||||
"home_assistant": {
|
||||
"url": settings.home_assistant.url,
|
||||
"token": "",
|
||||
|
||||
+74
-4
@@ -1,7 +1,7 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use crate::models::{HomeAssistantSettings, RuntimeSettings};
|
||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, RuntimeSettings};
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(author, version, about)]
|
||||
@@ -51,9 +51,15 @@ impl Config {
|
||||
discovery_broadcast: self.discovery_broadcast.clone(),
|
||||
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
|
||||
control_strategy: "setpoint".into(),
|
||||
outdoor_assist_enabled: env::var("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(true),
|
||||
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
|
||||
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
||||
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
|
||||
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
|
||||
influxdb: influx_settings_from_env(),
|
||||
debug: DebugSettings {
|
||||
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
||||
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
||||
},
|
||||
home_assistant: HomeAssistantSettings {
|
||||
url: env::var("HA_URL").unwrap_or_default(),
|
||||
token: env::var("HA_TOKEN").unwrap_or_default(),
|
||||
@@ -65,4 +71,68 @@ impl Config {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment values explicitly supplied by the service override persisted runtime values.
|
||||
pub fn apply_runtime_env_overrides(&self, settings: &mut RuntimeSettings) {
|
||||
if env::var_os("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").is_some() {
|
||||
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
|
||||
|
||||
let influx_env_present = [
|
||||
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
|
||||
"GREE_CONTROLLER_INFLUX_DATABASE", "GREE_CONTROLLER_INFLUX_USERNAME", "GREE_CONTROLLER_INFLUX_PASSWORD",
|
||||
"GREE_CONTROLLER_INFLUX_ORG", "GREE_CONTROLLER_INFLUX_BUCKET", "GREE_CONTROLLER_INFLUX_TOKEN",
|
||||
"GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS", "INFLUXDB_URL", "INFLUXDB_DATABASE", "INFLUXDB_USERNAME",
|
||||
"INFLUXDB_PASSWORD", "INFLUXDB_TOKEN", "INFLUXDB_ORG", "INFLUXDB_BUCKET",
|
||||
].iter().any(|name| env::var_os(name).is_some());
|
||||
if influx_env_present {
|
||||
let env_settings = influx_settings_from_env();
|
||||
if env::var_os("GREE_CONTROLLER_INFLUX_ENABLED").is_some() {
|
||||
settings.influxdb.enabled = env_settings.enabled;
|
||||
} else if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() {
|
||||
settings.influxdb.enabled = true;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() { settings.influxdb.version = env_settings.version; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.url = env_settings.url; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() { settings.influxdb.database = env_settings.database; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() { settings.influxdb.username = env_settings.username; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() { settings.influxdb.password = env_settings.password; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() { settings.influxdb.org = env_settings.org; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() { settings.influxdb.bucket = env_settings.bucket; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() { settings.influxdb.token = env_settings.token; }
|
||||
if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() { settings.influxdb.history_threshold_days = env_settings.history_threshold_days; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_bool(name: &str) -> Option<bool> {
|
||||
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
|
||||
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() }
|
||||
|
||||
fn first_env(names: &[&str]) -> Option<String> {
|
||||
names.iter().find_map(|name| {
|
||||
let value = env::var(name).ok()?;
|
||||
(!value.trim().is_empty()).then_some(value)
|
||||
})
|
||||
}
|
||||
|
||||
fn influx_settings_from_env() -> InfluxDbSettings {
|
||||
let mut settings = InfluxDbSettings::default();
|
||||
settings.version = first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).unwrap_or_else(|| "2".into());
|
||||
settings.url = first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).unwrap_or_default();
|
||||
settings.enabled = env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty());
|
||||
settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).unwrap_or_else(|| "gree_controller".into());
|
||||
settings.username = first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default();
|
||||
settings.password = first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default();
|
||||
settings.org = first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).unwrap_or_default();
|
||||
settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).unwrap_or_else(|| "gree_controller".into());
|
||||
settings.token = first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default();
|
||||
settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").unwrap_or(30).clamp(1, 3650);
|
||||
settings
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
models::{ApiTokenInfo, Automation, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
models::{ApiTokenInfo, Automation, ConfigurationExport, Device, EventLog, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
queries,
|
||||
};
|
||||
|
||||
@@ -252,6 +252,40 @@ impl Db {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn history_before(&self, before: DateTime<Utc>, limit_per_family: u32) -> Result<(Vec<Reading>, Vec<ZoneReading>, Vec<HaReading>)> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit_per_family.clamp(1, 5_000) as i64;
|
||||
let before = before.to_rfc3339();
|
||||
|
||||
let devices = {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BEFORE)?;
|
||||
let rows = stmt.query_map(params![before.clone(), limit], Self::map_reading)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
let zones = {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BEFORE)?;
|
||||
let rows = stmt.query_map(params![before.clone(), limit], Self::map_zone_reading)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
let ha = {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BEFORE)?;
|
||||
let rows = stmt.query_map(params![before, limit], Self::map_ha_reading)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
Ok((devices, zones, ha))
|
||||
}
|
||||
|
||||
pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result<u64> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
let mut changed = 0_u64;
|
||||
for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; }
|
||||
for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; }
|
||||
for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; }
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn prune_readings(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
@@ -261,6 +295,31 @@ impl Db {
|
||||
Ok(device + zone + ha)
|
||||
}
|
||||
|
||||
/// Compact history to the same practical resolution used by charts.
|
||||
/// 1-7 days: one sample / 10 minutes, 7+ days: one sample / 30 minutes.
|
||||
pub fn compact_history(&self, retention_days: i64) -> Result<u64> {
|
||||
let now = Utc::now();
|
||||
let one_day = now - Duration::days(1);
|
||||
let seven_days = now - Duration::days(7);
|
||||
let retention = now - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
let mut changed = 0_u64;
|
||||
for (bucket, older_than, newer_than) in [
|
||||
(600_i64, one_day, seven_days),
|
||||
(1800_i64, seven_days, retention),
|
||||
] {
|
||||
if older_than <= newer_than { continue; }
|
||||
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
|
||||
changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64;
|
||||
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
|
||||
changed += conn.execute(queries::COMPACT_ZONE_HISTORY, args)? as u64;
|
||||
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
|
||||
changed += conn.execute(queries::COMPACT_HA_HISTORY, args)? as u64;
|
||||
}
|
||||
conn.execute_batch("PRAGMA optimize;")?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
|
||||
let conn = self.lock()?;
|
||||
@@ -451,6 +510,44 @@ impl Db {
|
||||
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
|
||||
}
|
||||
|
||||
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
|
||||
Ok(ConfigurationExport {
|
||||
format_version: 1,
|
||||
exported_at: Utc::now(),
|
||||
settings,
|
||||
devices: self.list_devices()?,
|
||||
zones: self.list_zones()?,
|
||||
schedules: self.list_schedules()?,
|
||||
automations: self.list_automations()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn replace_configuration(&self, export: &ConfigurationExport) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(queries::CLEAR_CONFIGURATION)?;
|
||||
for device in &export.devices {
|
||||
let payload = Self::to_json(device)?;
|
||||
tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
for zone in &export.zones {
|
||||
let payload = Self::to_json(zone)?;
|
||||
tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
for schedule in &export.schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
for item in &export.automations {
|
||||
let payload = Self::to_json(item)?;
|
||||
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
|
||||
}
|
||||
let settings_json = Self::to_json(&export.settings)?;
|
||||
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> {
|
||||
let conn = self.lock()?;
|
||||
let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?;
|
||||
@@ -473,6 +570,26 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::models::{ApiTokenInfo, Device, HaReading, Reading};
|
||||
|
||||
#[test]
|
||||
fn history_compaction_keeps_one_sample_per_old_bucket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open(&dir.path().join("compact.db")).unwrap();
|
||||
let device = Device::simulated_default();
|
||||
db.save_device(&device).unwrap();
|
||||
let seconds = (Utc::now().timestamp() - 2 * 86_400) / 600 * 600;
|
||||
let base = DateTime::<Utc>::from_timestamp(seconds, 0).unwrap();
|
||||
for offset in [10_i64, 20_i64] {
|
||||
db.add_reading(&Reading {
|
||||
id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset),
|
||||
indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0,
|
||||
power: true, source: "gree".into(),
|
||||
}).unwrap();
|
||||
}
|
||||
assert_eq!(db.history_counts().unwrap().0, 2);
|
||||
assert_eq!(db.compact_history(30).unwrap(), 1);
|
||||
assert_eq!(db.history_counts().unwrap().0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
+254
-14
@@ -6,7 +6,8 @@ use tokio::time::sleep;
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{Automation, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneReading},
|
||||
influxdb,
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -40,23 +41,67 @@ pub fn start(state: AppState) {
|
||||
|
||||
let maintenance_state = state;
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
loop {
|
||||
sleep(Duration::from_secs(6 * 60 * 60)).await;
|
||||
match maintenance_state.db.prune_readings(30) {
|
||||
Ok(count) if count > 0 => tracing::info!(count, "old readings pruned"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
|
||||
let settings = maintenance_state.settings.read().await.clone();
|
||||
// When InfluxDB is enabled, compact all locally retained legacy history before
|
||||
// transferring old buckets. Without Influx, compact only the configured retention window.
|
||||
let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64;
|
||||
if settings.history_compaction_enabled {
|
||||
match maintenance_state.db.compact_history(compaction_days) {
|
||||
Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot compact history"),
|
||||
}
|
||||
}
|
||||
if settings.influxdb.enabled {
|
||||
match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await {
|
||||
Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"),
|
||||
}
|
||||
} else {
|
||||
let retention_days = settings.history_retention_days.max(1) as i64;
|
||||
match maintenance_state.db.prune_readings(retention_days) {
|
||||
Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
|
||||
}
|
||||
}
|
||||
sleep(Duration::from_secs(6 * 60 * 60)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result<u64> {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(threshold_days.max(1) as i64);
|
||||
let settings = state.settings.read().await.influxdb.clone();
|
||||
let mut moved = 0_u64;
|
||||
// Bound one maintenance pass so a very large legacy database never monopolizes the runtime.
|
||||
// Successful batches are deleted from SQLite, so the next pass naturally continues forward.
|
||||
for _ in 0..50 {
|
||||
let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?;
|
||||
if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; }
|
||||
influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?;
|
||||
let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?;
|
||||
moved += deleted;
|
||||
if deleted == 0 { break; }
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
validate_command(&command)?;
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
|
||||
|
||||
// Do not wake/beep a unit for fields that already match the last known state.
|
||||
// Offline devices still receive the full request because their cached state may be stale.
|
||||
let command = if device.online { command.changed_from(&device) } else { command };
|
||||
if command.is_empty() { return Ok(device); }
|
||||
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
||||
|
||||
if device.simulated {
|
||||
command.apply(&mut device);
|
||||
device.online = true;
|
||||
@@ -79,7 +124,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(first_err) = state.gree.command(&device, &command).await {
|
||||
if let Err(first_err) = state.gree.command(&device, &command, suppress_beep).await {
|
||||
// Retry once after a fresh bind. This covers stale keys and devices that
|
||||
// switched between ECB/GCM after a firmware update.
|
||||
let retry_result = match state.gree.bind(&device).await {
|
||||
@@ -87,7 +132,7 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
state.db.save_device(&device)?;
|
||||
state.gree.command(&device, &command).await
|
||||
state.gree.command(&device, &command, suppress_beep).await
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
};
|
||||
@@ -198,7 +243,7 @@ fn simulate_tick(device: &mut Device) {
|
||||
}
|
||||
|
||||
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
state.db.add_reading(&Reading {
|
||||
let reading = Reading {
|
||||
id: 0,
|
||||
device_id: device.id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
@@ -207,7 +252,9 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
target_temperature: device.target_temperature,
|
||||
power: device.power,
|
||||
source: if device.simulated { "simulator".into() } else { "gree".into() },
|
||||
})?;
|
||||
};
|
||||
state.db.add_reading(&reading)?;
|
||||
queue_influx_device(state, reading);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -476,8 +523,10 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio
|
||||
active_preset: zone.active_preset.clone(),
|
||||
};
|
||||
let interval = poll_interval_seconds.max(15) as i64;
|
||||
if let Err(err) = state.db.add_zone_reading_if_due(&reading, interval) {
|
||||
tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample");
|
||||
match state.db.add_zone_reading_if_due(&reading, interval) {
|
||||
Ok(true) => queue_influx_zone(state, reading),
|
||||
Ok(false) => {}
|
||||
Err(err) => tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,11 +547,46 @@ fn record_ha_history(
|
||||
temperature,
|
||||
};
|
||||
let interval = poll_interval_seconds.max(15) as i64;
|
||||
if let Err(err) = state.db.add_ha_reading_if_due(&reading, interval) {
|
||||
tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample");
|
||||
match state.db.add_ha_reading_if_due(&reading, interval) {
|
||||
Ok(true) => queue_influx_ha(state, reading),
|
||||
Ok(false) => {}
|
||||
Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_influx_device(state: &AppState, reading: Reading) {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let settings = state.settings.read().await.influxdb.clone();
|
||||
if !settings.enabled { return; }
|
||||
if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await {
|
||||
tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn queue_influx_zone(state: &AppState, reading: ZoneReading) {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let settings = state.settings.read().await.influxdb.clone();
|
||||
if !settings.enabled { return; }
|
||||
if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await {
|
||||
tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn queue_influx_ha(state: &AppState, reading: HaReading) {
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let settings = state.settings.read().await.influxdb.clone();
|
||||
if !settings.enabled { return; }
|
||||
if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await {
|
||||
tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
|
||||
match zone.sensor_source.as_str() {
|
||||
"home_assistant" => match (external_temperature, device_temperature) {
|
||||
@@ -649,6 +733,143 @@ fn previous_weekday(day: Weekday) -> Weekday {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
let now = Local::now();
|
||||
let mut zones_out = Vec::new();
|
||||
let mut house_events = Vec::new();
|
||||
|
||||
for zone in state.db.list_zones()? {
|
||||
let device = devices.iter().find(|item| item.id == zone.device_id);
|
||||
let effective_mode = if settings.house_mode == "off" {
|
||||
"off"
|
||||
} else if zone.inherit_house_mode {
|
||||
settings.house_mode.as_str()
|
||||
} else {
|
||||
zone.mode.as_str()
|
||||
};
|
||||
let active = active_schedule_for_zone(&zone, &schedules, now);
|
||||
let (preset, target) = if effective_mode == "off" {
|
||||
("off".to_string(), None)
|
||||
} else {
|
||||
let (preset, target) = resolve_zone_target(&zone, active, effective_mode);
|
||||
(preset, Some(target))
|
||||
};
|
||||
let next_events = next_schedule_events(&zone, &schedules, effective_mode, now, 8);
|
||||
for event in next_events.iter().take(2) {
|
||||
let mut event = event.clone();
|
||||
event.label = format!("{}: {}", zone.name, event.label);
|
||||
house_events.push(event);
|
||||
}
|
||||
zones_out.push(ZoneControlPlan {
|
||||
zone_id: zone.id.clone(),
|
||||
zone_name: zone.name.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
|
||||
enabled: zone.enabled,
|
||||
mode: effective_mode.to_string(),
|
||||
preset: if effective_mode == "off" { "off".into() } else if zone.active_preset.is_empty() { preset } else { zone.active_preset.clone() },
|
||||
current_temperature: zone.current_temperature,
|
||||
target_temperature: if effective_mode == "off" { None } else { zone.effective_setpoint.or(target) },
|
||||
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
|
||||
demand: zone.enabled && effective_mode != "off" && zone.demand,
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
manual_override_until: zone.manual_override_until,
|
||||
current_schedule_id: active.map(|item| item.id.clone()),
|
||||
current_schedule_name: active.map(|item| item.name.clone()),
|
||||
next_events,
|
||||
});
|
||||
}
|
||||
let mut rules = Vec::new();
|
||||
for item in state.db.list_automations()? {
|
||||
let action_name = devices.iter().find(|device| device.id == item.action_device_id).map(|device| device.name.clone()).unwrap_or_else(|| item.action_device_id.clone());
|
||||
let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone());
|
||||
let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64));
|
||||
if item.enabled && item.trigger_kind == "time" {
|
||||
if let Some(event) = next_time_automation_event(&item, &action_name, now) {
|
||||
house_events.push(event);
|
||||
}
|
||||
}
|
||||
rules.push(AutomationPlanRule {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
enabled: item.enabled,
|
||||
trigger_kind: item.trigger_kind,
|
||||
trigger_device_id: item.trigger_device_id,
|
||||
trigger_device_name: trigger_name,
|
||||
threshold: item.threshold,
|
||||
at_time: item.at_time,
|
||||
action_device_id: item.action_device_id,
|
||||
action_device_name: action_name,
|
||||
action: item.action,
|
||||
last_fired_at: item.last_fired_at,
|
||||
next_ready_at,
|
||||
});
|
||||
}
|
||||
|
||||
house_events.sort_by_key(|event| event.at);
|
||||
house_events.truncate(12);
|
||||
|
||||
Ok(ControlPlan {
|
||||
generated_at: Utc::now(),
|
||||
house_mode: settings.house_mode,
|
||||
outdoor_temperature: *state.outdoor_temperature.read().await,
|
||||
control_strategy: settings.control_strategy,
|
||||
next_events: house_events,
|
||||
zones: zones_out,
|
||||
rules,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> {
|
||||
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
||||
for minute in 1..=(24 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
|
||||
return Some(ControlPlanEvent {
|
||||
at: candidate.with_timezone(&Utc),
|
||||
kind: "automation".into(),
|
||||
label: format!("{} -> {}", item.name, action_name),
|
||||
preset: None,
|
||||
target_temperature: item.action.target_temperature,
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> {
|
||||
if mode == "off" { return Vec::new(); }
|
||||
let mut events = Vec::new();
|
||||
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
|
||||
for minute in 1..=(8 * 24 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let next = active_schedule_for_zone(zone, schedules, candidate);
|
||||
let next_id = next.map(|item| item.id.as_str());
|
||||
if next_id == current { continue; }
|
||||
current = next_id;
|
||||
let (preset, target, label) = if let Some(item) = next {
|
||||
let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) };
|
||||
(Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target))
|
||||
} else {
|
||||
let target = profile_setpoint(zone, "comfort", mode);
|
||||
(Some("comfort".into()), Some(target), format!("comfort {:.1} C", target))
|
||||
};
|
||||
events.push(ControlPlanEvent {
|
||||
at: candidate.with_timezone(&Utc),
|
||||
kind: "schedule_transition".into(),
|
||||
label,
|
||||
preset,
|
||||
target_temperature: target,
|
||||
});
|
||||
if events.len() >= limit { break; }
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
let devices = state.db.list_devices()?;
|
||||
for mut item in state.db.list_automations()? {
|
||||
@@ -721,6 +942,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_command_drops_unchanged_fields() {
|
||||
let device = Device::simulated_default();
|
||||
let command = DeviceCommand {
|
||||
power: Some(false),
|
||||
mode: Some("cool".into()),
|
||||
target_temperature: Some(23.4),
|
||||
fan_speed: Some(3),
|
||||
light: Some(false),
|
||||
..DeviceCommand::default()
|
||||
};
|
||||
let changed = command.changed_from(&device);
|
||||
assert_eq!(changed.power, None);
|
||||
assert_eq!(changed.mode, None);
|
||||
assert_eq!(changed.target_temperature, None);
|
||||
assert_eq!(changed.fan_speed, Some(3));
|
||||
assert_eq!(changed.light, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_prefers_room_sensor_weight() {
|
||||
let zone = test_zone("combined");
|
||||
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::models::{HaReading, InfluxDbSettings, Reading, ZoneReading};
|
||||
|
||||
const DEVICE_MEASUREMENT: &str = "gree_device";
|
||||
const ZONE_MEASUREMENT: &str = "gree_zone";
|
||||
const HA_MEASUREMENT: &str = "gree_ha";
|
||||
|
||||
pub fn validate(settings: &InfluxDbSettings) -> Result<()> {
|
||||
if !settings.enabled { return Ok(()); }
|
||||
if !matches!(settings.version.as_str(), "1" | "2") { bail!("InfluxDB version must be 1 or 2"); }
|
||||
let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") { bail!("InfluxDB URL must use http or https"); }
|
||||
if settings.version == "1" && settings.database.trim().is_empty() { bail!("InfluxDB 1.x database is required"); }
|
||||
if settings.version == "2" {
|
||||
if settings.org.trim().is_empty() { bail!("InfluxDB 2.x organization is required"); }
|
||||
if settings.bucket.trim().is_empty() { bail!("InfluxDB 2.x bucket is required"); }
|
||||
if settings.token.trim().is_empty() { bail!("InfluxDB 2.x token is required"); }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn write_device(client: &Client, settings: &InfluxDbSettings, reading: &Reading) -> Result<()> {
|
||||
if !settings.enabled { return Ok(()); }
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature);
|
||||
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
|
||||
push_float(&mut fields, "target_temperature", Some(reading.target_temperature));
|
||||
push_int(&mut fields, "power", reading.power as i64);
|
||||
let line = line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?;
|
||||
write_line(client, settings, line).await
|
||||
}
|
||||
|
||||
pub async fn write_zone(client: &Client, settings: &InfluxDbSettings, reading: &ZoneReading) -> Result<()> {
|
||||
if !settings.enabled { return Ok(()); }
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
|
||||
push_float(&mut fields, "external_temperature", reading.external_temperature);
|
||||
push_float(&mut fields, "control_temperature", reading.control_temperature);
|
||||
push_float(&mut fields, "target_temperature", reading.target_temperature);
|
||||
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
|
||||
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
|
||||
push_int(&mut fields, "power", reading.power as i64);
|
||||
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
|
||||
push_int(&mut fields, "demand", reading.demand as i64);
|
||||
let line = line_protocol(
|
||||
ZONE_MEASUREMENT,
|
||||
&[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)],
|
||||
fields,
|
||||
reading.timestamp,
|
||||
)?;
|
||||
write_line(client, settings, line).await
|
||||
}
|
||||
|
||||
pub async fn write_ha(client: &Client, settings: &InfluxDbSettings, reading: &HaReading) -> Result<()> {
|
||||
if !settings.enabled { return Ok(()); }
|
||||
let zone = reading.zone_id.as_deref().unwrap_or("");
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "temperature", Some(reading.temperature));
|
||||
let line = line_protocol(
|
||||
HA_MEASUREMENT,
|
||||
&[("entity_id", &reading.entity_id), ("zone_id", zone), ("kind", &reading.kind)],
|
||||
fields,
|
||||
reading.timestamp,
|
||||
)?;
|
||||
write_line(client, settings, line).await
|
||||
}
|
||||
|
||||
pub async fn write_batch(
|
||||
client: &Client,
|
||||
settings: &InfluxDbSettings,
|
||||
devices: &[Reading],
|
||||
zones: &[ZoneReading],
|
||||
ha: &[HaReading],
|
||||
) -> Result<()> {
|
||||
if !settings.enabled { return Ok(()); }
|
||||
let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len());
|
||||
for reading in devices {
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "indoor_temperature", reading.indoor_temperature);
|
||||
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
|
||||
push_float(&mut fields, "target_temperature", Some(reading.target_temperature));
|
||||
push_int(&mut fields, "power", reading.power as i64);
|
||||
lines.push(line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?);
|
||||
}
|
||||
for reading in zones {
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "gree_temperature", reading.gree_temperature);
|
||||
push_float(&mut fields, "external_temperature", reading.external_temperature);
|
||||
push_float(&mut fields, "control_temperature", reading.control_temperature);
|
||||
push_float(&mut fields, "target_temperature", reading.target_temperature);
|
||||
push_float(&mut fields, "device_setpoint", reading.device_setpoint);
|
||||
push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature);
|
||||
push_int(&mut fields, "power", reading.power as i64);
|
||||
push_int(&mut fields, "fan_speed", reading.fan_speed as i64);
|
||||
push_int(&mut fields, "demand", reading.demand as i64);
|
||||
lines.push(line_protocol(ZONE_MEASUREMENT, &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], fields, reading.timestamp)?);
|
||||
}
|
||||
for reading in ha {
|
||||
let mut fields = Vec::new();
|
||||
push_float(&mut fields, "temperature", Some(reading.temperature));
|
||||
lines.push(line_protocol(HA_MEASUREMENT, &[("entity_id", &reading.entity_id), ("zone_id", reading.zone_id.as_deref().unwrap_or("")), ("kind", &reading.kind)], fields, reading.timestamp)?);
|
||||
}
|
||||
if lines.is_empty() { return Ok(()); }
|
||||
write_lines(client, settings, lines.join("\n")).await
|
||||
}
|
||||
|
||||
async fn write_line(client: &Client, settings: &InfluxDbSettings, line: String) -> Result<()> {
|
||||
write_lines(client, settings, line).await
|
||||
}
|
||||
|
||||
async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String) -> Result<()> {
|
||||
validate(settings)?;
|
||||
let base = settings.url.trim_end_matches('/');
|
||||
let request = if settings.version == "1" {
|
||||
let request = client.post(format!("{base}/write"))
|
||||
.query(&[("db", settings.database.as_str()), ("precision", "ns")])
|
||||
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(body.clone());
|
||||
if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) }
|
||||
} else {
|
||||
client.post(format!("{base}/api/v2/write"))
|
||||
.query(&[("org", settings.org.as_str()), ("bucket", settings.bucket.as_str()), ("precision", "ns")])
|
||||
.bearer_auth(settings.token.trim())
|
||||
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(body)
|
||||
};
|
||||
let response = request.send().await.context("InfluxDB write request failed")?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("InfluxDB write failed ({status}): {}", truncate(&body, 300));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn query_devices(
|
||||
client: &Client,
|
||||
settings: &InfluxDbSettings,
|
||||
device_id: Option<&str>,
|
||||
start: DateTime<Utc>,
|
||||
stop: DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<Reading>> {
|
||||
if settings.version == "1" {
|
||||
query_devices_v1(client, settings, device_id, start, stop, bucket_seconds, limit).await
|
||||
} else {
|
||||
let tags = if let Some(value) = device_id { format!(" |> filter(fn: (r) => r.device_id == {})", flux_string(value)) } else { String::new() };
|
||||
let query = flux_query(settings, DEVICE_MEASUREMENT, &tags, &["device_id"], start, stop, bucket_seconds);
|
||||
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("device_id").filter(|v| !v.is_empty()) else { continue; };
|
||||
out.push(Reading {
|
||||
id: 0,
|
||||
device_id: id.clone(),
|
||||
timestamp,
|
||||
indoor_temperature: row_f64(&row, "indoor_temperature"),
|
||||
outdoor_temperature: row_f64(&row, "outdoor_temperature"),
|
||||
target_temperature: row_f64(&row, "target_temperature").unwrap_or(0.0),
|
||||
power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5,
|
||||
source: "influx".into(),
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|row| row.timestamp);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_zones(
|
||||
client: &Client,
|
||||
settings: &InfluxDbSettings,
|
||||
zone_id: Option<&str>,
|
||||
start: DateTime<Utc>,
|
||||
stop: DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ZoneReading>> {
|
||||
if settings.version == "1" {
|
||||
query_zones_v1(client, settings, zone_id, start, stop, bucket_seconds, limit).await
|
||||
} else {
|
||||
let tags = if let Some(value) = zone_id { format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) } else { String::new() };
|
||||
let query = flux_query(settings, ZONE_MEASUREMENT, &tags, &["zone_id", "device_id"], start, stop, bucket_seconds);
|
||||
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(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { continue; };
|
||||
out.push(ZoneReading {
|
||||
id: 0,
|
||||
zone_id: zone.clone(),
|
||||
device_id: row.get("device_id").cloned().unwrap_or_default(),
|
||||
timestamp,
|
||||
gree_temperature: row_f64(&row, "gree_temperature"),
|
||||
external_temperature: row_f64(&row, "external_temperature"),
|
||||
control_temperature: row_f64(&row, "control_temperature"),
|
||||
target_temperature: row_f64(&row, "target_temperature"),
|
||||
device_setpoint: row_f64(&row, "device_setpoint"),
|
||||
outdoor_temperature: row_f64(&row, "outdoor_temperature"),
|
||||
power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5,
|
||||
mode: "history".into(),
|
||||
fan_speed: row_f64(&row, "fan_speed").unwrap_or(0.0).round().clamp(0.0, 5.0) as u8,
|
||||
demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5,
|
||||
control_source: "influx".into(),
|
||||
active_preset: "history".into(),
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|row| row.timestamp);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_ha(
|
||||
client: &Client,
|
||||
settings: &InfluxDbSettings,
|
||||
entity_id: Option<&str>,
|
||||
start: DateTime<Utc>,
|
||||
stop: DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<HaReading>> {
|
||||
if settings.version == "1" {
|
||||
query_ha_v1(client, settings, entity_id, start, stop, bucket_seconds, limit).await
|
||||
} else {
|
||||
let tags = if let Some(value) = entity_id { format!(" |> filter(fn: (r) => r.entity_id == {})", flux_string(value)) } else { String::new() };
|
||||
let query = flux_query(settings, HA_MEASUREMENT, &tags, &["entity_id", "zone_id", "kind"], start, stop, bucket_seconds);
|
||||
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(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { continue; };
|
||||
let Some(temperature) = row_f64(&row, "temperature") else { continue; };
|
||||
out.push(HaReading {
|
||||
id: 0,
|
||||
entity_id: entity.clone(),
|
||||
zone_id: row.get("zone_id").filter(|v| !v.is_empty()).cloned(),
|
||||
kind: row.get("kind").cloned().unwrap_or_else(|| "room".into()),
|
||||
timestamp,
|
||||
temperature,
|
||||
});
|
||||
}
|
||||
out.sort_by_key(|row| row.timestamp);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_devices_v1(client: &Client, settings: &InfluxDbSettings, device_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<Reading>> {
|
||||
let filter = device_id.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))).unwrap_or_default();
|
||||
let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
|
||||
let series = query_v1(client, settings, &q).await?;
|
||||
let mut out = Vec::new();
|
||||
for item in series {
|
||||
let device = item.tags.get("device_id").cloned().unwrap_or_default();
|
||||
for row in item.rows {
|
||||
let Some(timestamp) = row_time(&row) else { continue; };
|
||||
out.push(Reading { id:0, device_id:device.clone(), timestamp, indoor_temperature:row_num(&row,"indoor_temperature"), outdoor_temperature:row_num(&row,"outdoor_temperature"), target_temperature:row_num(&row,"target_temperature").unwrap_or(0.0), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, source:"influx".into() });
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
|
||||
}
|
||||
|
||||
async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<ZoneReading>> {
|
||||
let filter = zone_id.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))).unwrap_or_default();
|
||||
let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
|
||||
let series = query_v1(client, settings, &q).await?;
|
||||
let mut out = Vec::new();
|
||||
for item in series {
|
||||
let zone = item.tags.get("zone_id").cloned().unwrap_or_default();
|
||||
let device = item.tags.get("device_id").cloned().unwrap_or_default();
|
||||
for row in item.rows {
|
||||
let Some(timestamp) = row_time(&row) else { continue; };
|
||||
out.push(ZoneReading { id:0, zone_id:zone.clone(), device_id:device.clone(), timestamp, gree_temperature:row_num(&row,"gree_temperature"), external_temperature:row_num(&row,"external_temperature"), control_temperature:row_num(&row,"control_temperature"), target_temperature:row_num(&row,"target_temperature"), device_setpoint:row_num(&row,"device_setpoint"), outdoor_temperature:row_num(&row,"outdoor_temperature"), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, mode:"history".into(), fan_speed:row_num(&row,"fan_speed").unwrap_or(0.0).round().clamp(0.0,5.0) as u8, demand:row_num(&row,"demand").unwrap_or(0.0)>=0.5, control_source:"influx".into(), active_preset:"history".into() });
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
|
||||
}
|
||||
|
||||
async fn query_ha_v1(client: &Client, settings: &InfluxDbSettings, entity_id: Option<&str>, start: DateTime<Utc>, stop: DateTime<Utc>, bucket: i64, limit: u32) -> Result<Vec<HaReading>> {
|
||||
let filter = entity_id.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))).unwrap_or_default();
|
||||
let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit);
|
||||
let series = query_v1(client, settings, &q).await?;
|
||||
let mut out = Vec::new();
|
||||
for item in series {
|
||||
let entity = item.tags.get("entity_id").cloned().unwrap_or_default();
|
||||
let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned();
|
||||
let kind = item.tags.get("kind").cloned().unwrap_or_else(|| "room".into());
|
||||
for row in item.rows {
|
||||
let Some(timestamp) = row_time(&row) else { continue; };
|
||||
let Some(temperature) = row_num(&row,"temperature") else { continue; };
|
||||
out.push(HaReading { id:0, entity_id:entity.clone(), zone_id:zone.clone(), kind:kind.clone(), timestamp, temperature });
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out)
|
||||
}
|
||||
|
||||
struct V1Series { tags: HashMap<String,String>, rows: Vec<HashMap<String,Value>> }
|
||||
|
||||
async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result<Vec<V1Series>> {
|
||||
validate(settings)?;
|
||||
let base = settings.url.trim_end_matches('/');
|
||||
let request = client.get(format!("{base}/query")).query(&[("db", settings.database.as_str()), ("q", q)]);
|
||||
let request = if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) };
|
||||
let response = request.send().await.context("InfluxDB 1.x query failed")?;
|
||||
let status = response.status();
|
||||
let body: Value = response.json().await.context("invalid InfluxDB 1.x JSON response")?;
|
||||
if !status.is_success() { bail!("InfluxDB 1.x query failed ({status}): {body}"); }
|
||||
if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { bail!("InfluxDB 1.x query error: {error}"); }
|
||||
let mut out = Vec::new();
|
||||
for series in body.pointer("/results/0/series").and_then(Value::as_array).into_iter().flatten() {
|
||||
let columns: Vec<String> = series.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
|
||||
let tags = series.get("tags").and_then(Value::as_object).map(|map| map.iter().map(|(k,v)|(k.clone(),v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default();
|
||||
let mut rows = Vec::new();
|
||||
for values in series.get("values").and_then(Value::as_array).into_iter().flatten() {
|
||||
let Some(values) = values.as_array() else { continue; };
|
||||
rows.push(columns.iter().cloned().zip(values.iter().cloned()).collect());
|
||||
}
|
||||
out.push(V1Series { tags, rows });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn query_v2(client: &Client, settings: &InfluxDbSettings, query: &str) -> Result<Vec<HashMap<String,String>>> {
|
||||
validate(settings)?;
|
||||
let base = settings.url.trim_end_matches('/');
|
||||
let response = client.post(format!("{base}/api/v2/query"))
|
||||
.query(&[("org", settings.org.as_str())])
|
||||
.bearer_auth(settings.token.trim())
|
||||
.header(reqwest::header::ACCEPT, "application/csv")
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/vnd.flux")
|
||||
.body(query.to_string())
|
||||
.send().await.context("InfluxDB 2.x query failed")?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.context("cannot read InfluxDB 2.x response")?;
|
||||
if !status.is_success() { bail!("InfluxDB 2.x query failed ({status}): {}", truncate(&body, 500)); }
|
||||
let mut headers: Option<Vec<String>> = None;
|
||||
let mut rows = Vec::new();
|
||||
for line in body.lines().filter(|line| !line.starts_with('#') && !line.trim().is_empty()) {
|
||||
let record = parse_csv_line(line);
|
||||
if headers.is_none() {
|
||||
headers = Some(record);
|
||||
continue;
|
||||
}
|
||||
let row: HashMap<String,String> = headers.as_ref().unwrap().iter().cloned().zip(record.into_iter()).collect();
|
||||
if row.get("_time").map(|value| !value.is_empty()).unwrap_or(false) { rows.push(row); }
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn parse_csv_line(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut field = String::new();
|
||||
let mut chars = line.chars().peekable();
|
||||
let mut quoted = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'"' if quoted && chars.peek() == Some(&'"') => {
|
||||
field.push('"');
|
||||
chars.next();
|
||||
}
|
||||
'"' => quoted = !quoted,
|
||||
',' if !quoted => {
|
||||
out.push(std::mem::take(&mut field));
|
||||
}
|
||||
_ => field.push(ch),
|
||||
}
|
||||
}
|
||||
out.push(field);
|
||||
out
|
||||
}
|
||||
|
||||
fn flux_query(settings: &InfluxDbSettings, measurement: &str, extra_filters: &str, group_tags: &[&str], start: DateTime<Utc>, stop: DateTime<Utc>, bucket_seconds: i64) -> String {
|
||||
let tags = group_tags.iter().map(|tag| format!("\"{tag}\"")).collect::<Vec<_>>().join(",");
|
||||
format!(
|
||||
"from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])",
|
||||
flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags
|
||||
)
|
||||
}
|
||||
|
||||
fn line_protocol(measurement: &str, tags: &[(&str, &str)], fields: Vec<String>, timestamp: DateTime<Utc>) -> Result<String> {
|
||||
if fields.is_empty() { bail!("InfluxDB measurement has no fields"); }
|
||||
let tags = tags.iter().filter(|(_, value)| !value.is_empty()).map(|(key,value)| format!(",{}={}", escape_tag(key), escape_tag(value))).collect::<String>();
|
||||
let nanos = timestamp.timestamp_nanos_opt().ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?;
|
||||
Ok(format!("{}{} {} {}", escape_measurement(measurement), tags, fields.join(","), nanos))
|
||||
}
|
||||
|
||||
fn push_float(fields: &mut Vec<String>, key: &str, value: Option<f64>) { if let Some(value) = value.filter(|v| v.is_finite()) { fields.push(format!("{}={value}", escape_field_key(key))); } }
|
||||
fn push_int(fields: &mut Vec<String>, key: &str, value: i64) { fields.push(format!("{}={value}i", escape_field_key(key))); }
|
||||
fn escape_measurement(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace(' ', "\\ ") }
|
||||
fn escape_tag(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ") }
|
||||
fn escape_field_key(value: &str) -> String { escape_tag(value) }
|
||||
fn influxql_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") }
|
||||
fn flux_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) }
|
||||
fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() }
|
||||
fn row_f64(row: &HashMap<String,String>, key: &str) -> Option<f64> { row.get(key)?.parse().ok() }
|
||||
fn parse_flux_time(row: &HashMap<String,String>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("_time")?).ok().map(|v| v.with_timezone(&Utc)) }
|
||||
fn row_num(row: &HashMap<String,Value>, key: &str) -> Option<f64> { row.get(key)?.as_f64().or_else(|| row.get(key)?.as_i64().map(|v|v as f64)) }
|
||||
fn row_time(row: &HashMap<String,Value>) -> Option<DateTime<Utc>> { DateTime::parse_from_rfc3339(row.get("time")?.as_str()?).ok().map(|v|v.with_timezone(&Utc)) }
|
||||
+8
-2
@@ -4,12 +4,13 @@ mod db;
|
||||
mod engine;
|
||||
mod error;
|
||||
mod home_assistant;
|
||||
mod influxdb;
|
||||
mod models;
|
||||
mod protocol;
|
||||
mod queries;
|
||||
mod state;
|
||||
|
||||
use std::{sync::Arc, time::{Duration, Instant}};
|
||||
use std::{sync::{Arc, atomic::AtomicBool}, time::{Duration, Instant}};
|
||||
use anyhow::{Context, Result};
|
||||
use config::Config;
|
||||
use db::Db;
|
||||
@@ -31,6 +32,7 @@ async fn main() -> Result<()> {
|
||||
if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() {
|
||||
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
|
||||
}
|
||||
config.apply_runtime_env_overrides(&mut runtime_settings);
|
||||
db.save_runtime_settings(&runtime_settings)?;
|
||||
|
||||
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
|
||||
@@ -43,7 +45,8 @@ async fn main() -> Result<()> {
|
||||
)?;
|
||||
}
|
||||
|
||||
let (events, _) = broadcast::channel(256);
|
||||
let (events, _) = broadcast::channel(512);
|
||||
let debug_gree_frames = Arc::new(AtomicBool::new(runtime_settings.debug.gree_frames));
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
|
||||
@@ -55,10 +58,13 @@ async fn main() -> Result<()> {
|
||||
gree: GreeClient::new(
|
||||
runtime_settings.controller_id.clone(),
|
||||
(!config.gree_interface.trim().is_empty()).then(|| config.gree_interface.trim().to_string()),
|
||||
Some(events.clone()),
|
||||
debug_gree_frames.clone(),
|
||||
),
|
||||
events,
|
||||
http,
|
||||
outdoor_temperature: Arc::new(RwLock::new(None)),
|
||||
debug_gree_frames,
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
|
||||
+159
@@ -26,6 +26,10 @@ fn default_cool_away() -> f64 { 27.0 }
|
||||
fn default_heat_comfort() -> f64 { 21.0 }
|
||||
fn default_heat_sleep() -> f64 { 19.0 }
|
||||
fn default_heat_away() -> f64 { 17.0 }
|
||||
fn default_history_retention_days() -> u32 { 30 }
|
||||
fn default_influx_version() -> String { "2".into() }
|
||||
fn default_influx_database() -> String { "gree_controller".into() }
|
||||
fn default_influx_threshold_days() -> u32 { 30 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
@@ -148,6 +152,27 @@ pub struct DeviceCommand {
|
||||
}
|
||||
|
||||
impl DeviceCommand {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none()
|
||||
&& self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none()
|
||||
&& self.quiet.is_none() && self.turbo.is_none() && self.light.is_none()
|
||||
}
|
||||
|
||||
/// Return only fields that differ from the last known device state.
|
||||
pub fn changed_from(&self, device: &Device) -> Self {
|
||||
Self {
|
||||
power: self.power.filter(|value| *value != device.power),
|
||||
mode: self.mode.as_ref().filter(|value| value.as_str() != device.mode.as_str()).cloned(),
|
||||
target_temperature: self.target_temperature.filter(|value| value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()),
|
||||
fan_speed: self.fan_speed.filter(|value| (*value).min(5) != device.fan_speed),
|
||||
swing_vertical: self.swing_vertical.filter(|value| *value != device.swing_vertical),
|
||||
swing_horizontal: self.swing_horizontal.filter(|value| *value != device.swing_horizontal),
|
||||
quiet: self.quiet.filter(|value| *value != device.quiet),
|
||||
turbo: self.turbo.filter(|value| *value != device.turbo),
|
||||
light: self.light.filter(|value| *value != device.light),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply(&self, device: &mut Device) {
|
||||
if let Some(v) = self.power { device.power = v; }
|
||||
if let Some(v) = &self.mode { device.mode = v.clone(); }
|
||||
@@ -386,6 +411,128 @@ pub struct HomeAssistantSettings {
|
||||
pub allow_invalid_tls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InfluxDbSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// InfluxDB API generation: `1` or `2`.
|
||||
#[serde(default = "default_influx_version")]
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
/// InfluxDB 1.x database name.
|
||||
#[serde(default = "default_influx_database")]
|
||||
pub database: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
/// InfluxDB 2.x organization.
|
||||
#[serde(default)]
|
||||
pub org: String,
|
||||
/// InfluxDB 2.x bucket.
|
||||
#[serde(default = "default_influx_database")]
|
||||
pub bucket: String,
|
||||
#[serde(default)]
|
||||
pub token: String,
|
||||
/// Queries older than this age are read from InfluxDB when it is enabled.
|
||||
#[serde(default = "default_influx_threshold_days")]
|
||||
pub history_threshold_days: u32,
|
||||
}
|
||||
|
||||
impl Default for InfluxDbSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
version: default_influx_version(),
|
||||
url: String::new(),
|
||||
database: default_influx_database(),
|
||||
username: String::new(),
|
||||
password: String::new(),
|
||||
org: String::new(),
|
||||
bucket: default_influx_database(),
|
||||
token: String::new(),
|
||||
history_threshold_days: default_influx_threshold_days(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DebugSettings {
|
||||
#[serde(default)]
|
||||
pub overlay_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub gree_frames: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigurationExport {
|
||||
pub format_version: u32,
|
||||
pub exported_at: DateTime<Utc>,
|
||||
pub settings: RuntimeSettings,
|
||||
pub devices: Vec<Device>,
|
||||
pub zones: Vec<Zone>,
|
||||
pub schedules: Vec<Schedule>,
|
||||
pub automations: Vec<Automation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ControlPlanEvent {
|
||||
pub at: DateTime<Utc>,
|
||||
pub kind: String,
|
||||
pub label: String,
|
||||
pub preset: Option<String>,
|
||||
pub target_temperature: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZoneControlPlan {
|
||||
pub zone_id: String,
|
||||
pub zone_name: String,
|
||||
pub device_id: String,
|
||||
pub device_name: String,
|
||||
pub enabled: bool,
|
||||
pub mode: String,
|
||||
pub preset: String,
|
||||
pub current_temperature: Option<f64>,
|
||||
pub target_temperature: Option<f64>,
|
||||
pub device_setpoint: Option<f64>,
|
||||
pub demand: bool,
|
||||
pub control_source: String,
|
||||
pub manual_override_until: Option<DateTime<Utc>>,
|
||||
pub current_schedule_id: Option<String>,
|
||||
pub current_schedule_name: Option<String>,
|
||||
pub next_events: Vec<ControlPlanEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AutomationPlanRule {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub enabled: bool,
|
||||
pub trigger_kind: String,
|
||||
pub trigger_device_id: Option<String>,
|
||||
pub trigger_device_name: Option<String>,
|
||||
pub threshold: Option<f64>,
|
||||
pub at_time: Option<String>,
|
||||
pub action_device_id: String,
|
||||
pub action_device_name: String,
|
||||
pub action: DeviceCommand,
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
pub next_ready_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ControlPlan {
|
||||
pub generated_at: DateTime<Utc>,
|
||||
pub house_mode: String,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub control_strategy: String,
|
||||
pub next_events: Vec<ControlPlanEvent>,
|
||||
pub zones: Vec<ZoneControlPlan>,
|
||||
pub rules: Vec<AutomationPlanRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettings {
|
||||
pub controller_id: String,
|
||||
@@ -402,6 +549,18 @@ pub struct RuntimeSettings {
|
||||
pub control_strategy: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub outdoor_assist_enabled: bool,
|
||||
/// Keep recent metrics locally; older history may live in InfluxDB.
|
||||
#[serde(default = "default_history_retention_days")]
|
||||
pub history_retention_days: u32,
|
||||
#[serde(default = "default_true")]
|
||||
pub history_compaction_enabled: bool,
|
||||
/// Add protocol-specific buzzer suppression fields to command frames.
|
||||
#[serde(default)]
|
||||
pub suppress_device_beep: bool,
|
||||
#[serde(default)]
|
||||
pub influxdb: InfluxDbSettings,
|
||||
#[serde(default)]
|
||||
pub debug: DebugSettings,
|
||||
pub home_assistant: HomeAssistantSettings,
|
||||
}
|
||||
|
||||
|
||||
+73
-8
@@ -1,10 +1,10 @@
|
||||
use std::{collections::HashSet, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, time::Duration};
|
||||
use std::{collections::HashSet, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}}, time::Duration};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::{net::UdpSocket, time::{timeout, Instant}};
|
||||
use tokio::{net::UdpSocket, sync::broadcast, time::{timeout, Instant}};
|
||||
use uuid::Uuid;
|
||||
use crate::models::{Device, DeviceCommand};
|
||||
use crate::models::{ApiEvent, Device, DeviceCommand};
|
||||
use super::crypto::{
|
||||
decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2,
|
||||
GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY,
|
||||
@@ -20,11 +20,46 @@ pub struct BindResult {
|
||||
pub struct GreeClient {
|
||||
controller_id: String,
|
||||
interface: Option<String>,
|
||||
debug_events: Option<broadcast::Sender<ApiEvent>>,
|
||||
debug_gree_frames: Arc<AtomicBool>,
|
||||
buzzer_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl GreeClient {
|
||||
pub fn new(controller_id: String, interface: Option<String>) -> Self {
|
||||
Self { controller_id, interface }
|
||||
pub fn new(
|
||||
controller_id: String,
|
||||
interface: Option<String>,
|
||||
debug_events: Option<broadcast::Sender<ApiEvent>>,
|
||||
debug_gree_frames: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
Self {
|
||||
controller_id,
|
||||
interface,
|
||||
debug_events,
|
||||
debug_gree_frames,
|
||||
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) {
|
||||
if !self.debug_gree_frames.load(Ordering::Relaxed) { return; }
|
||||
let Some(events) = &self.debug_events else { return; };
|
||||
let mut safe = payload.clone();
|
||||
if let Some(object) = safe.as_object_mut() {
|
||||
if object.contains_key("key") { object.insert("key".into(), json!("***")); }
|
||||
}
|
||||
let _ = events.send(ApiEvent {
|
||||
event: "gree.frame".into(),
|
||||
timestamp: Utc::now(),
|
||||
data: json!({
|
||||
"direction": direction,
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"target": target.to_string(),
|
||||
"protocol_version": protocol,
|
||||
"payload": safe,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> {
|
||||
@@ -379,8 +414,32 @@ impl GreeClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand) -> Result<Value> {
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let try_buzzer_suppression = suppress_beep
|
||||
&& self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true);
|
||||
let inner = Self::command_payload(command, try_buzzer_suppression)?;
|
||||
match self.request(device, &inner, key, false, device.protocol_version).await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(first_err) if try_buzzer_suppression => {
|
||||
// Some firmwares reject unknown command properties instead of ignoring them.
|
||||
// Retry the exact state change without buzzer fields; only remember the device
|
||||
// as incompatible after that fallback succeeds.
|
||||
let fallback = Self::command_payload(command, false)?;
|
||||
match self.request(device, &fallback, key, false, device.protocol_version).await {
|
||||
Ok(value) => {
|
||||
if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device");
|
||||
Ok(value)
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
}
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result<Value> {
|
||||
let mut opt = Vec::<&str>::new();
|
||||
let mut values = Vec::<Value>::new();
|
||||
if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
@@ -398,8 +457,11 @@ impl GreeClient {
|
||||
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if opt.is_empty() { bail!("empty device command") }
|
||||
let inner = json!({"opt": opt, "p": values, "t": "cmd"});
|
||||
self.request(device, &inner, key, false, device.protocol_version).await
|
||||
if suppress_beep {
|
||||
opt.push("Buzzer_ON_OFF"); values.push(json!(1));
|
||||
opt.push("BuzzerCtrl"); values.push(json!(0));
|
||||
}
|
||||
Ok(json!({"opt": opt, "p": values, "t": "cmd"}))
|
||||
}
|
||||
|
||||
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
|
||||
@@ -430,6 +492,7 @@ impl GreeClient {
|
||||
}
|
||||
let payload = serde_json::to_vec(&outer)?;
|
||||
tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, wire_mac=%wire_mac, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request");
|
||||
self.debug_frame("tx", device, target, version, inner);
|
||||
socket.send_to(&payload, target).await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
@@ -458,6 +521,7 @@ impl GreeClient {
|
||||
}
|
||||
}
|
||||
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
|
||||
self.debug_frame("rx", device, target, version, &decoded);
|
||||
return Ok(decoded);
|
||||
}
|
||||
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
|
||||
@@ -485,6 +549,7 @@ impl GreeClient {
|
||||
if !response_type.eq_ignore_ascii_case("bindok") { continue; }
|
||||
}
|
||||
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
|
||||
self.debug_frame("rx", device, target, version, &decoded);
|
||||
return Ok(decoded);
|
||||
}
|
||||
if let Some(err) = last_decode_error { return Err(err); }
|
||||
|
||||
@@ -233,6 +233,12 @@ ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICE_HISTORY_BEFORE: &str = r#"
|
||||
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
|
||||
FROM readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2
|
||||
"#;
|
||||
pub const DELETE_READING_BY_ID: &str = "DELETE FROM readings WHERE id=?1";
|
||||
|
||||
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
|
||||
|
||||
pub const INSERT_ZONE_READING_IF_DUE: &str = r#"
|
||||
@@ -276,6 +282,13 @@ LIMIT ?3
|
||||
|
||||
pub const DELETE_ZONE_READINGS_BY_ZONE_ID: &str = "DELETE FROM zone_readings WHERE zone_id=?1";
|
||||
pub const DELETE_ZONE_READINGS_BY_DEVICE_ID: &str = "DELETE FROM zone_readings WHERE device_id=?1";
|
||||
pub const LIST_ZONE_HISTORY_BEFORE: &str = r#"
|
||||
SELECT id,zone_id,device_id,timestamp,gree_temperature,external_temperature,control_temperature,
|
||||
target_temperature,device_setpoint,outdoor_temperature,power,mode,fan_speed,demand,control_source,active_preset
|
||||
FROM zone_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2
|
||||
"#;
|
||||
pub const DELETE_ZONE_READING_BY_ID: &str = "DELETE FROM zone_readings WHERE id=?1";
|
||||
|
||||
pub const PRUNE_ZONE_READINGS: &str = "DELETE FROM zone_readings WHERE timestamp < ?1";
|
||||
|
||||
pub const INSERT_HA_READING_IF_DUE: &str = r#"
|
||||
@@ -306,8 +319,58 @@ ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const LIST_HA_HISTORY_BEFORE: &str = r#"
|
||||
SELECT id,entity_id,zone_id,kind,timestamp,temperature
|
||||
FROM ha_readings WHERE timestamp < ?1 ORDER BY timestamp ASC LIMIT ?2
|
||||
"#;
|
||||
pub const DELETE_HA_READING_BY_ID: &str = "DELETE FROM ha_readings WHERE id=?1";
|
||||
|
||||
pub const PRUNE_HA_READINGS: &str = "DELETE FROM ha_readings WHERE timestamp < ?1";
|
||||
|
||||
|
||||
// Tiered history compaction keeps only the resolution the charts can actually display.
|
||||
pub const COMPACT_DEVICE_HISTORY: &str = r#"
|
||||
DELETE FROM readings WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY device_id, CAST(unixepoch(timestamp)/?1 AS INTEGER)
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
) AS rn
|
||||
FROM readings WHERE timestamp < ?2 AND timestamp >= ?3
|
||||
) WHERE rn > 1
|
||||
)
|
||||
"#;
|
||||
|
||||
pub const COMPACT_ZONE_HISTORY: &str = r#"
|
||||
DELETE FROM zone_readings WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY zone_id, CAST(unixepoch(timestamp)/?1 AS INTEGER)
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
) AS rn
|
||||
FROM zone_readings WHERE timestamp < ?2 AND timestamp >= ?3
|
||||
) WHERE rn > 1
|
||||
)
|
||||
"#;
|
||||
|
||||
pub const COMPACT_HA_HISTORY: &str = r#"
|
||||
DELETE FROM ha_readings WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY entity_id, COALESCE(zone_id,''), kind, CAST(unixepoch(timestamp)/?1 AS INTEGER)
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
) AS rn
|
||||
FROM ha_readings WHERE timestamp < ?2 AND timestamp >= ?3
|
||||
) WHERE rn > 1
|
||||
)
|
||||
"#;
|
||||
|
||||
pub const CLEAR_CONFIGURATION: &str = r#"
|
||||
DELETE FROM schedules;
|
||||
DELETE FROM automations;
|
||||
DELETE FROM zones;
|
||||
DELETE FROM devices;
|
||||
"#;
|
||||
pub const HISTORY_COUNTS: &str = r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM readings),
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use std::{sync::{Arc, atomic::AtomicBool}, time::Instant};
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
@@ -13,6 +13,7 @@ pub struct AppState {
|
||||
pub events: broadcast::Sender<ApiEvent>,
|
||||
pub http: reqwest::Client,
|
||||
pub outdoor_temperature: Arc<RwLock<Option<f64>>>,
|
||||
pub debug_gree_frames: Arc<AtomicBool>,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user