v0.8.14
This commit is contained in:
+18
-2703
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let base = if !state.config.base_path.is_empty() {
|
||||
state.config.base_path.clone()
|
||||
} else {
|
||||
forwarded_prefix(&headers).unwrap_or_default()
|
||||
};
|
||||
let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base);
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
|
||||
let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim();
|
||||
if raw.is_empty() || raw == "/" { return Some(String::new()); }
|
||||
if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; }
|
||||
Some(format!("/{}", raw.trim_matches('/')))
|
||||
}
|
||||
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=86400") }
|
||||
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
|
||||
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") }
|
||||
async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") }
|
||||
async fn language_index() -> Response {
|
||||
static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache")
|
||||
}
|
||||
async fn language_file(Path(file): Path<String>) -> Response {
|
||||
let code = file.strip_suffix(".json").unwrap_or(&file);
|
||||
if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) {
|
||||
return static_response(*body, "application/json; charset=utf-8", "no-cache");
|
||||
}
|
||||
let mut response = Response::new(Body::from("Language not found"));
|
||||
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response
|
||||
}
|
||||
fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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() {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
let supplied = request_token(&request);
|
||||
if supplied.as_deref() != Some(expected) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
async fn home_assistant_auth(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let supplied = request_token(&request).ok_or(AppError::Unauthorized)?;
|
||||
let admin_token = state.config.app_token.trim();
|
||||
if !admin_token.is_empty() && supplied == admin_token {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
if state.db.api_token_exists(&hash_token(&supplied))? {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
|
||||
fn request_token(request: &Request) -> Option<String> {
|
||||
request.headers().get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok()))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes()))
|
||||
}
|
||||
|
||||
fn generate_access_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
let mut rng = OsRng;
|
||||
rng.fill_bytes(&mut bytes);
|
||||
format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AutomationInput {
|
||||
name: String,
|
||||
#[serde(default = "yes")]
|
||||
enabled: bool,
|
||||
trigger_kind: String,
|
||||
#[serde(default)]
|
||||
trigger_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
threshold: Option<f64>,
|
||||
#[serde(default)]
|
||||
at_time: Option<String>,
|
||||
#[serde(default)]
|
||||
action_device_id: String,
|
||||
#[serde(default)]
|
||||
action_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
action_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
action: DeviceCommand,
|
||||
#[serde(default = "automation_cooldown")]
|
||||
cooldown_seconds: u64,
|
||||
}
|
||||
fn automation_cooldown() -> u64 { 300 }
|
||||
impl AutomationInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); }
|
||||
match self.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("temperature trigger needs device and threshold".into()));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?;
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported automation trigger".into())),
|
||||
}
|
||||
let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
if action_group_id.is_none() && self.action_device_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("automation action needs a device or group".into()));
|
||||
}
|
||||
if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if action_group_id.is_none() {
|
||||
return Err(AppError::BadRequest("automation preset actions require a group target".into()));
|
||||
}
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("unsupported group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if action_group_id.is_some() {
|
||||
if let Some(mode) = self.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||
}
|
||||
}
|
||||
if self.action.target_temperature.is_some()
|
||||
|| self.action.fan_speed.is_some()
|
||||
|| self.action.swing_vertical.is_some()
|
||||
|| self.action.swing_horizontal.is_some()
|
||||
|| self.action.quiet.is_some()
|
||||
|| self.action.turbo.is_some()
|
||||
|| self.action.light.is_some()
|
||||
|| self.action.air.is_some()
|
||||
|| self.action.xfan.is_some()
|
||||
|| self.action.health.is_some()
|
||||
|| self.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into()));
|
||||
}
|
||||
if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() {
|
||||
return Err(AppError::BadRequest("group automation action cannot be empty".into()));
|
||||
}
|
||||
} else {
|
||||
engine::validate_command(&self.action)?;
|
||||
if self.action.is_empty() {
|
||||
return Err(AppError::BadRequest("automation action cannot be empty".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
|
||||
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(),
|
||||
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
||||
created_at, updated_at: Utc::now() }
|
||||
}
|
||||
}
|
||||
fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> {
|
||||
if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") {
|
||||
let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default();
|
||||
if state.db.get_device(trigger_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation trigger device does not exist".into()));
|
||||
}
|
||||
}
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if state.db.get_group(group_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action group does not exist".into()));
|
||||
}
|
||||
} else if state.db.get_device(input.action_device_id.trim())?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
|
||||
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> {
|
||||
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
||||
}
|
||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
input.validate()?;
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||
input.validate()?;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
Ok(Json(item))
|
||||
}
|
||||
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
|
||||
state.broadcast("automation.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
async fn list_access_tokens(State(state): State<AppState>) -> Result<Json<Vec<ApiTokenInfo>>, AppError> {
|
||||
Ok(Json(state.db.list_api_tokens()?))
|
||||
}
|
||||
|
||||
async fn create_access_token(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateAccessTokenRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), AppError> {
|
||||
let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string();
|
||||
if name.is_empty() || name.len() > 80 {
|
||||
return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into()));
|
||||
}
|
||||
|
||||
let secret = generate_access_token();
|
||||
let item = ApiTokenInfo {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name,
|
||||
token_prefix: format!("{}...", secret.chars().take(24).collect::<String>()),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
state.db.save_api_token(&item, &hash_token(&secret))?;
|
||||
state.log(
|
||||
"info",
|
||||
"access_token.created",
|
||||
"Created a Home Assistant access token",
|
||||
json!({"token_id": item.id.clone(), "name": item.name.clone()}),
|
||||
);
|
||||
Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item}))))
|
||||
}
|
||||
|
||||
async fn delete_access_token(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if !state.db.delete_api_token(&id)? {
|
||||
return Err(AppError::NotFound(format!("access token {id}")));
|
||||
}
|
||||
state.log(
|
||||
"info",
|
||||
"access_token.revoked",
|
||||
"Revoked a Home Assistant access token",
|
||||
json!({"token_id": id}),
|
||||
);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000);
|
||||
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
|
||||
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
|
||||
let passes = request.passes.unwrap_or(3).clamp(1, 10);
|
||||
let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
let mut saved = Vec::new();
|
||||
let mut new_device_ids = Vec::new();
|
||||
for item in discovered {
|
||||
let existing = state.db.get_device_by_mac(&item.mac)?;
|
||||
let is_new = existing.is_none();
|
||||
let mut merged = merge_discovered(existing, item);
|
||||
let _device_guard = state.lock_device_operation(&merged.id).await;
|
||||
// A poll/command may have updated the same known device between discovery and
|
||||
// acquiring its operation lock. Re-merge against the freshest persisted state.
|
||||
if !is_new {
|
||||
if let Some(current) = state.db.get_device(&merged.id)? {
|
||||
merged = merge_discovered(Some(current), merged);
|
||||
}
|
||||
}
|
||||
// Bind right after discovery. GREE modules can have a short bind window;
|
||||
// bind() also refreshes it with a direct scan before the handshake.
|
||||
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&merged).await {
|
||||
Ok(bound) => {
|
||||
merged.key = Some(bound.key);
|
||||
merged.protocol_version = bound.protocol_version;
|
||||
merged.communication_failures = 0;
|
||||
merged.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
merged.last_error = Some(format!("discovered, bind pending: {err}"));
|
||||
state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
state.db.save_device(&merged)?;
|
||||
if is_new { new_device_ids.push(merged.id.clone()); }
|
||||
saved.push(merged);
|
||||
}
|
||||
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()}));
|
||||
state.broadcast("devices.discovered", json!({"devices": saved}));
|
||||
Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids})))
|
||||
}
|
||||
|
||||
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
|
||||
Ok(Json(state.db.list_devices()?))
|
||||
}
|
||||
|
||||
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> {
|
||||
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name, mac and ip are required".into()));
|
||||
}
|
||||
input.ip.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
|
||||
if state.db.get_device_by_mac(&input.mac)?.is_some() {
|
||||
return Err(AppError::BadRequest("a device with this MAC already exists".into()));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase();
|
||||
let device = Device {
|
||||
id: format!("gree-{}", normalized_mac.to_ascii_lowercase()),
|
||||
mac: normalized_mac,
|
||||
name: input.name.trim().to_string(),
|
||||
ip: input.ip,
|
||||
port: input.port,
|
||||
protocol_version: input.protocol_version.min(2),
|
||||
model: String::new(),
|
||||
firmware: String::new(),
|
||||
key: input.key.filter(|v| !v.trim().is_empty()),
|
||||
cid: Some("app".into()),
|
||||
enabled: true,
|
||||
simulated: input.simulated,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
quiet: false,
|
||||
turbo: false,
|
||||
light: true,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: None,
|
||||
supports_quiet: None,
|
||||
supports_turbo: None,
|
||||
supports_air: None,
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
current_temperature: if input.simulated { Some(25.0) } else { None },
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: input.simulated,
|
||||
response_time_ms: if input.simulated { Some(0) } else { None },
|
||||
last_seen: if input.simulated { Some(now) } else { None },
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id}));
|
||||
state.broadcast("device.created", serde_json::to_value(&device)?);
|
||||
Ok((StatusCode::CREATED, Json(device)))
|
||||
}
|
||||
|
||||
async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||
state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}")))
|
||||
}
|
||||
|
||||
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
|
||||
if patch.enabled == Some(false) {
|
||||
engine::disable_device_safely(&state, &id).await?;
|
||||
}
|
||||
let _device_guard = state.lock_device_operation(&id).await;
|
||||
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } }
|
||||
if let Some(v) = patch.ip { v.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
|
||||
if let Some(v) = patch.port { device.port = v; }
|
||||
if let Some(v) = patch.protocol_version {
|
||||
let v = v.min(2);
|
||||
if device.protocol_version != v {
|
||||
device.protocol_version = v;
|
||||
device.key = None;
|
||||
device.supports_light = None;
|
||||
device.supports_quiet = None;
|
||||
device.supports_turbo = None;
|
||||
device.supports_air = None;
|
||||
device.supports_xfan = None;
|
||||
device.supports_health = None;
|
||||
device.supports_sleep = None;
|
||||
}
|
||||
}
|
||||
if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); }
|
||||
if let Some(v) = patch.enabled { device.enabled = v; }
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
Ok(Json(device))
|
||||
}
|
||||
|
||||
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
if state.db.list_automations()?.iter().any(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
}) {
|
||||
return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into()));
|
||||
}
|
||||
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.device_id == id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
remove_zone_ids_from_groups(&state, &removed_zone_ids)?;
|
||||
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||
state.broadcast("device.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||
let _device_guard = state.lock_device_operation(&id).await;
|
||||
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.simulated { return Ok(Json(device)); }
|
||||
let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id}));
|
||||
Ok(Json(device))
|
||||
}
|
||||
|
||||
async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(engine::poll_one(&state, &id).await?))
|
||||
}
|
||||
|
||||
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?))
|
||||
}
|
||||
|
||||
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
|
||||
if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) {
|
||||
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into()));
|
||||
}
|
||||
Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventsQuery { limit: Option<u32> }
|
||||
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventRetentionInput { days: u32 }
|
||||
|
||||
async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
|
||||
let days = state.settings.read().await.event_log_retention_days;
|
||||
Json(json!({"days": days}))
|
||||
}
|
||||
|
||||
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.event_log_retention_days = input.days.clamp(1, 3650);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
let days = settings.event_log_retention_days;
|
||||
drop(settings);
|
||||
let removed = state.db.prune_events(days as i64)?;
|
||||
state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed}));
|
||||
let public = { let settings = state.settings.read().await; public_settings(&*settings) };
|
||||
state.broadcast("settings.updated", public);
|
||||
Ok(Json(json!({"days": days, "removed": removed})))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GroupInput {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
zone_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
power_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> {
|
||||
let mut values: Vec<String> = zone_ids.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
values
|
||||
}
|
||||
|
||||
fn validate_group_input(state: &AppState, input: &GroupInput) -> Result<Vec<String>, AppError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("group name is required".into()));
|
||||
}
|
||||
let zone_ids = normalize_group_zone_ids(input.zone_ids.clone());
|
||||
if zone_ids.is_empty() {
|
||||
return Err(AppError::BadRequest("group must contain at least one zone".into()));
|
||||
}
|
||||
for zone_id in &zone_ids {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::BadRequest(format!("group references missing zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
Ok(zone_ids)
|
||||
}
|
||||
|
||||
async fn list_groups(State(state): State<AppState>) -> Result<Json<Vec<ClimateGroup>>, AppError> {
|
||||
Ok(Json(state.db.list_groups()?))
|
||||
}
|
||||
|
||||
async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
}
|
||||
|
||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let now = Utc::now();
|
||||
let group = ClimateGroup {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: input.name.trim().to_string(),
|
||||
zone_ids,
|
||||
power_enabled: input.power_enabled.unwrap_or(true),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.created", serde_json::to_value(&group)?);
|
||||
state.wake_zone_control();
|
||||
Ok((StatusCode::CREATED, Json(group)))
|
||||
}
|
||||
|
||||
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let group = ClimateGroup {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
zone_ids,
|
||||
power_enabled: input.power_enabled.unwrap_or(existing.power_enabled),
|
||||
created_at: existing.created_at,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(group))
|
||||
}
|
||||
|
||||
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) {
|
||||
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
|
||||
}
|
||||
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
|
||||
state.broadcast("group.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
let automated_groups: std::collections::HashSet<String> = state.db.list_automations()?.into_iter()
|
||||
.filter_map(|item| item.action_group_id)
|
||||
.collect();
|
||||
for group in state.db.list_groups()? {
|
||||
let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count();
|
||||
if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) {
|
||||
return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
for mut group in state.db.list_groups()? {
|
||||
let before = group.zone_ids.len();
|
||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||
if group.zone_ids.len() == before { continue; }
|
||||
if group.zone_ids.is_empty() {
|
||||
state.db.delete_group(&group.id)?;
|
||||
state.broadcast("group.deleted", json!({"id": group.id}));
|
||||
continue;
|
||||
}
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_group_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<GroupControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
|
||||
}
|
||||
|
||||
fn home_assistant_group_mode(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
for zone in zones {
|
||||
let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() };
|
||||
if !matches!(current, "house" | "cool" | "heat") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
}
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
for zone in zones {
|
||||
let current = zone.manual_preset.as_deref().unwrap_or("auto");
|
||||
if !matches!(current, "auto" | "comfort" | "sleep" | "away") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
}
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||
let groups = state.db.list_groups()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
let plan = engine::build_control_plan(&state).await?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut output = Vec::with_capacity(groups.len());
|
||||
|
||||
for group in groups {
|
||||
let members = zones.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
|
||||
.collect::<Vec<_>>();
|
||||
let planned_members = plan.zones.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let zone_names = members.iter().map(|zone| zone.name.clone()).collect::<Vec<_>>();
|
||||
let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::<std::collections::HashSet<_>>();
|
||||
let online_devices = devices.iter()
|
||||
.filter(|device| member_device_ids.contains(device.id.as_str()) && device.online)
|
||||
.count();
|
||||
let current_temperatures = planned_members.iter()
|
||||
.filter_map(|zone| zone.current_temperature)
|
||||
.collect::<Vec<_>>();
|
||||
let current_temperature = if current_temperatures.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_temperatures.iter().sum::<f64>() / current_temperatures.len() as f64)
|
||||
};
|
||||
let mut next_events = Vec::new();
|
||||
for zone in &planned_members {
|
||||
for event in &zone.next_events {
|
||||
let mut event = event.clone();
|
||||
event.label = format!("{}: {}", zone.zone_name, event.label);
|
||||
next_events.push(event);
|
||||
}
|
||||
}
|
||||
next_events.sort_by_key(|event| event.at);
|
||||
next_events.truncate(8);
|
||||
let member_states = planned_members.iter().map(|zone| json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"device_manual_override": zone.device_manual_override,
|
||||
"device_manual_override_until": zone.device_manual_override_until,
|
||||
})).collect::<Vec<_>>();
|
||||
|
||||
output.push(json!({
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"zone_ids": group.zone_ids,
|
||||
"zone_names": zone_names,
|
||||
"power_enabled": group.power_enabled,
|
||||
"effective_power": settings.house_power_enabled && group.power_enabled,
|
||||
"mode": home_assistant_group_mode(&members),
|
||||
"preset": home_assistant_group_preset(&members),
|
||||
"house_mode": settings.house_mode,
|
||||
"zone_count": members.len(),
|
||||
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
|
||||
"active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(),
|
||||
"demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(),
|
||||
"device_count": member_device_ids.len(),
|
||||
"online_devices": online_devices,
|
||||
"current_temperature": current_temperature,
|
||||
"members": member_states,
|
||||
"next_events": next_events,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(output))
|
||||
}
|
||||
|
||||
async fn update_home_assistant_group_control(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#[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 * 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})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HistoryQuery {
|
||||
scope: Option<String>,
|
||||
zone_id: Option<String>,
|
||||
device_id: Option<String>,
|
||||
entity_id: Option<String>,
|
||||
hours: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
fn history_bucket_seconds(hours: i64) -> i64 {
|
||||
match hours {
|
||||
1..=6 => 30,
|
||||
7..=24 => 120,
|
||||
25..=168 => 600,
|
||||
169..=720 => 1800,
|
||||
721..=2160 => 7200,
|
||||
2161..=8760 => 21600,
|
||||
_ => 86400,
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec<Reading>) -> Vec<ZoneReading> {
|
||||
readings.into_iter().map(|reading| ZoneReading {
|
||||
id: reading.id,
|
||||
zone_id: zone.id.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
timestamp: reading.timestamp,
|
||||
gree_temperature: reading.indoor_temperature,
|
||||
external_temperature: None,
|
||||
control_temperature: reading.indoor_temperature,
|
||||
target_temperature: Some(reading.target_temperature),
|
||||
device_setpoint: Some(reading.target_temperature),
|
||||
outdoor_temperature: reading.outdoor_temperature,
|
||||
power: reading.power,
|
||||
mode: device.mode.clone(),
|
||||
fan_speed: device.fan_speed,
|
||||
demand: false,
|
||||
control_source: "gree_history_fallback".into(),
|
||||
active_preset: "history".into(),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn zone_history_with_fallback(
|
||||
state: &AppState,
|
||||
zone_id: Option<&str>,
|
||||
since: chrono::DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ZoneReading>, AppError> {
|
||||
let mut values = state.db.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?;
|
||||
if let Some(zone_id) = zone_id {
|
||||
if values.is_empty() {
|
||||
let zone = state.db.get_zone(zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
||||
if let Some(device) = state.db.get_device(&zone.device_id)? {
|
||||
let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?;
|
||||
values = fallback_zone_rows(&zone, &device, rows);
|
||||
}
|
||||
}
|
||||
return Ok(values);
|
||||
}
|
||||
|
||||
let existing: std::collections::HashSet<String> = values.iter().map(|row| row.zone_id.clone()).collect();
|
||||
for zone in state.db.list_zones()? {
|
||||
if existing.contains(&zone.id) { continue; }
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
|
||||
let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?;
|
||||
values.extend(fallback_zone_rows(&zone, &device, rows));
|
||||
}
|
||||
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
||||
if values.len() > limit as usize {
|
||||
let keep_from = values.len() - limit as usize;
|
||||
values.drain(0..keep_from);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn sensor_history_with_fallback(
|
||||
state: &AppState,
|
||||
since: chrono::DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
outdoor_entity: &str,
|
||||
) -> Result<Vec<HaReading>, AppError> {
|
||||
let mut values = state.db.list_ha_history(None, since.clone(), bucket_seconds, limit)?;
|
||||
let mut existing: std::collections::HashSet<String> = values.iter().map(|row| row.entity_id.clone()).collect();
|
||||
for zone in state.db.list_zones()? {
|
||||
let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; };
|
||||
if existing.contains(entity_id) { continue; }
|
||||
let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
|
||||
let mut added = false;
|
||||
for row in rows {
|
||||
if let Some(temperature) = row.external_temperature {
|
||||
values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: Some(zone.id.clone()), kind: "room".into(), timestamp: row.timestamp, temperature });
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if added { existing.insert(entity_id.to_string()); }
|
||||
}
|
||||
let outdoor_entity = outdoor_entity.trim();
|
||||
if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) {
|
||||
for zone in state.db.list_zones()? {
|
||||
let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?;
|
||||
let mut added = false;
|
||||
for row in rows {
|
||||
if let Some(temperature) = row.outdoor_temperature {
|
||||
values.push(HaReading { id: row.id, entity_id: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature });
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if added { break; }
|
||||
}
|
||||
}
|
||||
values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp));
|
||||
if values.len() > limit as usize {
|
||||
let keep_from = values.len() - limit as usize;
|
||||
values.drain(0..keep_from);
|
||||
}
|
||||
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 * 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);
|
||||
let scope = query.scope.as_deref().unwrap_or("zones");
|
||||
let outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone();
|
||||
let (device_count, zone_count, ha_count) = state.db.history_counts()?;
|
||||
|
||||
match scope {
|
||||
"devices" => {
|
||||
let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
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, 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_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?;
|
||||
let storage_warning = [zone_warning, device_warning, sensor_warning]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
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": storage_warning,
|
||||
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
|
||||
})))
|
||||
}
|
||||
"zones" | "zone" => {
|
||||
let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
|
||||
if let Some(zone_id) = zone_id {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::NotFound(format!("zone {zone_id}")));
|
||||
}
|
||||
}
|
||||
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}
|
||||
})))
|
||||
}
|
||||
_ => Err(AppError::BadRequest("history scope must be overview, zones, devices or sensors".into())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
|
||||
for mut group in state.db.list_groups()? {
|
||||
if group.power_enabled == power { continue; }
|
||||
group.power_enabled = power;
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut cleared = 0;
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
|
||||
let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
|
||||
let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
|
||||
zone.temporary_quick_thermostat = None;
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
if temporary_was_active {
|
||||
if let Some(enabled) = temporary_restore { zone.enabled = enabled; }
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
cleared += 1;
|
||||
}
|
||||
Ok(cleared)
|
||||
}
|
||||
|
||||
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
||||
let mut failed = Vec::new();
|
||||
let enabled_zone_devices: std::collections::HashSet<String> = if power {
|
||||
state.db.list_zones()?.into_iter()
|
||||
.filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false))
|
||||
.map(|zone| zone.device_id)
|
||||
.collect()
|
||||
} else {
|
||||
std::collections::HashSet::new()
|
||||
};
|
||||
for device in state.db.list_devices()? {
|
||||
if !device.enabled { continue; }
|
||||
// Whole-house ON only operates thermostat-managed, enabled zones. Devices with
|
||||
// a disabled zone (or no zone at all) remain manual/technical Devices controls.
|
||||
if power && !enabled_zone_devices.contains(&device.id) { continue; }
|
||||
// Do not trust the pre-loop power snapshot for deciding whether to send. The engine
|
||||
// reloads state under the per-device lock and turns an already-matching command into
|
||||
// a no-op. This closes the polling/command race without extra UDP frames.
|
||||
let result = if power {
|
||||
engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await
|
||||
} else {
|
||||
engine::force_house_power_off_device(state, &device.id, source).await
|
||||
};
|
||||
if let Err(err) = result {
|
||||
state.log("error", "house.power_all_error", &err.to_string(), json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"power": power,
|
||||
"source": source,
|
||||
}));
|
||||
failed.push(json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"error": err.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(failed)
|
||||
}
|
||||
|
||||
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
|
||||
}
|
||||
let mode = input.mode;
|
||||
let activate_all = mode != "off";
|
||||
let payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.house_mode = mode.clone();
|
||||
// Choosing a real whole-house operating mode is an explicit request to run the
|
||||
// house climate. It therefore clears a previous global power-off. "off" keeps
|
||||
// its separate meaning: do not control, without changing master power.
|
||||
if activate_all { settings.house_power_enabled = true; }
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
public_settings(&settings)
|
||||
};
|
||||
state.broadcast("settings.updated", payload.clone());
|
||||
if activate_all {
|
||||
set_all_groups_power(&state, true)?;
|
||||
// Never send a bare power=true frame. Wake the thermostat arbiter so every unit
|
||||
// starts only with a valid effective Heat/Cool mode and compressor lockout policy.
|
||||
state.wake_zone_control();
|
||||
}
|
||||
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all}));
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HousePowerPatch { power: bool }
|
||||
|
||||
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
|
||||
// Whole-house power is independent from the thermostat mode. Publish/persist the master
|
||||
// first so the regulator becomes passive before the one-shot OFF cascade starts.
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
if settings.house_power_enabled != input.power {
|
||||
settings.house_power_enabled = input.power;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
let payload = public_settings(&settings);
|
||||
state.broadcast("settings.updated", payload);
|
||||
}
|
||||
}
|
||||
|
||||
// Global power is a true cascade across group gates. OFF clears the current takeover
|
||||
// markers once, then each enabled device is re-cleared atomically with its OFF command.
|
||||
// A later pilot action is therefore not erased by subsequent controller cycles.
|
||||
set_all_groups_power(&state, input.power)?;
|
||||
if !input.power {
|
||||
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
|
||||
clear_all_local_thermostat_overrides(&state)?;
|
||||
}
|
||||
let failed = if input.power {
|
||||
state.wake_zone_control();
|
||||
Vec::new()
|
||||
} else {
|
||||
command_all_enabled_devices_power(&state, false, "house_power").await?
|
||||
};
|
||||
|
||||
let devices = state.db.list_devices()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let settings = state.settings.read().await;
|
||||
let settings_payload = public_settings(&settings);
|
||||
drop(settings);
|
||||
state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({
|
||||
"power": input.power,
|
||||
"failed": failed.len(),
|
||||
}));
|
||||
Ok(Json(json!({
|
||||
"power": input.power,
|
||||
"devices": devices,
|
||||
"groups": groups,
|
||||
"settings": settings_payload,
|
||||
"failed": failed,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HousePresetPatch { preset: String }
|
||||
|
||||
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
|
||||
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
|
||||
}
|
||||
|
||||
// A whole-house profile is also an explicit whole-house activation. This mirrors
|
||||
// selecting cooling/heating and makes the separate master-power control intuitive.
|
||||
let settings_payload = {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.house_power_enabled = true;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
public_settings(&settings)
|
||||
};
|
||||
state.broadcast("settings.updated", settings_payload.clone());
|
||||
set_all_groups_power(&state, true)?;
|
||||
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let mut zones = state.db.list_zones()?;
|
||||
for zone in &mut zones {
|
||||
if engine::temporary_quick_thermostat_is_active(zone, Utc::now()) {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.deferred_preset = Some(input.preset.clone());
|
||||
}
|
||||
} else if input.preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(input.preset.clone());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
}
|
||||
|
||||
// As with house mode/power ON, the central thermostat arbiter performs the physical
|
||||
// start with a valid mode/target. This prevents unmanaged power-on while house mode=off.
|
||||
let failed: Vec<Value> = Vec::new();
|
||||
state.wake_zone_control();
|
||||
let devices = state.db.list_devices()?;
|
||||
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
|
||||
"preset": input.preset,
|
||||
"master_power_enabled": true,
|
||||
"failed": failed.len(),
|
||||
}));
|
||||
Ok(Json(json!({
|
||||
"preset": input.preset,
|
||||
"zones": zones,
|
||||
"devices": devices,
|
||||
"settings": settings_payload,
|
||||
"failed": failed,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScheduleTemplateRequest { template: String }
|
||||
|
||||
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> {
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut items: Vec<Schedule> = Vec::new();
|
||||
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
||||
items.push(Schedule {
|
||||
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
|
||||
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
|
||||
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
});
|
||||
};
|
||||
let all = vec![1,2,3,4,5,6,7];
|
||||
match input.template.as_str() {
|
||||
"family" => {
|
||||
add("Comfort", all.clone(), "06:30", "22:30", "comfort");
|
||||
add("Sleep", all, "22:30", "06:30", "sleep");
|
||||
}
|
||||
"child" => {
|
||||
add("Comfort", all.clone(), "06:30", "20:30", "comfort");
|
||||
add("Sleep", all, "20:30", "06:30", "sleep");
|
||||
}
|
||||
"bedroom" => {
|
||||
add("Comfort", all.clone(), "06:30", "22:00", "comfort");
|
||||
add("Sleep", all, "22:00", "06:30", "sleep");
|
||||
}
|
||||
"workday" => {
|
||||
let weekdays = vec![1,2,3,4,5];
|
||||
let weekend = vec![6,7];
|
||||
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
|
||||
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
||||
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
||||
add("Sleep", weekdays, "22:30", "06:30", "sleep");
|
||||
add("Weekend", weekend, "08:00", "23:00", "comfort");
|
||||
// Saturday can sleep until the Sunday weekend block starts at 08:00.
|
||||
add("Saturday sleep", vec![6], "23:00", "08:00", "sleep");
|
||||
// Sunday must hand over at 06:30 so it never overlaps Monday morning.
|
||||
add("Sunday sleep", vec![7], "23:00", "06:30", "sleep");
|
||||
}
|
||||
"always" => add("Comfort", all, "00:00", "00:00", "comfort"),
|
||||
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
|
||||
}
|
||||
validate_schedule_set(&items)?;
|
||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||
refresh_zone_override_boundary(&state, &id)?;
|
||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||
}
|
||||
|
||||
async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
||||
Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?))
|
||||
}
|
||||
|
||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut removed = std::collections::HashSet::new();
|
||||
removed.insert(id.clone());
|
||||
ensure_zone_removal_safe(&state, &removed)?;
|
||||
ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?;
|
||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
remove_zone_ids_from_groups(&state, &removed)?;
|
||||
state.broadcast("zone.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaTestRequest { entity_id: Option<String> }
|
||||
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
|
||||
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(settings.home_assistant.sensor_stale_after_seconds))
|
||||
.await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
||||
}
|
||||
|
||||
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
|
||||
let old = state.settings.read().await.notifications.clone();
|
||||
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
|
||||
if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; }
|
||||
if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; }
|
||||
if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; }
|
||||
notifications::test(&state, input).await.map_err(AppError::Device)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
async fn security_headers(request: Request, next: Next) -> Response {
|
||||
let is_api = request.uri().path().contains("/api/");
|
||||
let mut response = next.run(request).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"));
|
||||
headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"));
|
||||
headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin"));
|
||||
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
|
||||
headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"));
|
||||
if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); }
|
||||
response
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
json!({
|
||||
"controller_id": settings.controller_id,
|
||||
"simulator_enabled": settings.simulator_enabled,
|
||||
"poll_interval_seconds": settings.poll_interval_seconds,
|
||||
"zone_interval_seconds": settings.zone_interval_seconds,
|
||||
"discovery_timeout_ms": settings.discovery_timeout_ms,
|
||||
"discovery_broadcast": settings.discovery_broadcast,
|
||||
"house_mode": settings.house_mode,
|
||||
"house_power_enabled": settings.house_power_enabled,
|
||||
"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,
|
||||
"event_log_retention_days": settings.event_log_retention_days,
|
||||
"suppress_device_beep": settings.suppress_device_beep,
|
||||
"debug": settings.debug,
|
||||
"night_mode": settings.night_mode,
|
||||
"notifications": {
|
||||
"enabled": settings.notifications.enabled,
|
||||
"mode": settings.notifications.mode,
|
||||
"provider": settings.notifications.provider,
|
||||
"pushover_app_token": "",
|
||||
"pushover_user_key": "",
|
||||
"pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(),
|
||||
"slack_webhook_url": "",
|
||||
"slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(),
|
||||
"discord_webhook_url": "",
|
||||
"discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(),
|
||||
"cooldown_seconds": settings.notifications.cooldown_seconds,
|
||||
"communication_failure_threshold": settings.notifications.communication_failure_threshold,
|
||||
"target_timeout_minutes": settings.notifications.target_timeout_minutes,
|
||||
"alert_types": &settings.notifications.alert_types,
|
||||
},
|
||||
"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": "",
|
||||
"token_configured": !settings.home_assistant.token.trim().is_empty(),
|
||||
"default_entity_id": settings.home_assistant.default_entity_id,
|
||||
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
|
||||
"sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds,
|
||||
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
|
||||
"sensor_aliases": settings.home_assistant.sensor_aliases,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScheduleInput {
|
||||
zone_id: String,
|
||||
name: String,
|
||||
#[serde(default = "yes")]
|
||||
enabled: bool,
|
||||
weekdays: Vec<u32>,
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
#[serde(default = "schedule_preset")]
|
||||
preset: String,
|
||||
setpoint: f64,
|
||||
}
|
||||
fn schedule_preset() -> String { "custom".into() }
|
||||
impl ScheduleInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); }
|
||||
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); }
|
||||
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?;
|
||||
if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); }
|
||||
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
|
||||
Ok(())
|
||||
}
|
||||
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
|
||||
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
|
||||
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
||||
}
|
||||
}
|
||||
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for other in items.iter().skip(index + 1) {
|
||||
if engine::schedules_overlap(item, other) {
|
||||
return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> {
|
||||
for existing in state.db.list_schedules()? {
|
||||
if exclude_id == Some(existing.id.as_str()) { continue; }
|
||||
if engine::schedules_overlap(item, &existing) {
|
||||
return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
|
||||
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
|
||||
.map(|session| session.finish_kind == "schedule_boundary")
|
||||
.unwrap_or(false);
|
||||
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); }
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
// An active Temporary Quick Thermostat explicitly owns its target until its own finish
|
||||
// rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and
|
||||
// accidentally clear that target at the next schedule transition.
|
||||
let temporary_owns_zone = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
|
||||
if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone {
|
||||
zone.manual_override_until = boundary;
|
||||
}
|
||||
if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; }
|
||||
if has_temporary_schedule_boundary {
|
||||
let reference = zone.temporary_quick_thermostat.as_ref()
|
||||
.filter(|session| session.activated_at.is_none())
|
||||
.map(|session| session.started_at.with_timezone(&chrono::Local))
|
||||
.unwrap_or_else(chrono::Local::now);
|
||||
let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now()));
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.expires_at = refreshed;
|
||||
}
|
||||
}
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
|
||||
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> {
|
||||
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
||||
}
|
||||
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> {
|
||||
input.validate()?;
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
|
||||
validate_schedule_conflicts(&state, &item, None)?;
|
||||
state.db.save_schedule(&item)?;
|
||||
refresh_zone_override_boundary(&state, &item.zone_id)?;
|
||||
state.broadcast("schedule.created", serde_json::to_value(&item)?);
|
||||
state.wake_zone_control();
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
|
||||
input.validate()?;
|
||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||
let old_zone_id = existing.zone_id.clone();
|
||||
let item = input.into_schedule(id.clone(), existing.created_at);
|
||||
validate_schedule_conflicts(&state, &item, Some(&id))?;
|
||||
state.db.save_schedule(&item)?;
|
||||
refresh_zone_override_boundary(&state, &old_zone_id)?;
|
||||
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; }
|
||||
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(item))
|
||||
}
|
||||
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
|
||||
refresh_zone_override_boundary(&state, &existing.zone_id)?;
|
||||
state.broadcast("schedule.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
||||
let settings = state.settings.read().await;
|
||||
Json(public_settings(&*settings))
|
||||
}
|
||||
|
||||
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
|
||||
let old = state.settings.read().await.clone();
|
||||
if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode {
|
||||
return Err(AppError::BadRequest(
|
||||
"house_power_enabled and house_mode must be changed through the House Control API".into(),
|
||||
));
|
||||
}
|
||||
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
|
||||
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
|
||||
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
|
||||
if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
|
||||
if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); }
|
||||
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|
||||
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
|
||||
input.discovery_broadcast.parse::<std::net::SocketAddr>()
|
||||
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
|
||||
}
|
||||
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.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400);
|
||||
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
|
||||
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
|
||||
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
|
||||
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
|
||||
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
|
||||
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
|
||||
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
|
||||
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
|
||||
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
|
||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||
normalize_sensor_aliases(&mut input);
|
||||
canonicalize_home_assistant_entities(&mut input);
|
||||
validate_night_mode(&mut input)?;
|
||||
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)?;
|
||||
canonicalize_saved_zone_entities(&state, &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)))
|
||||
}
|
||||
|
||||
fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||
settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases
|
||||
.iter()
|
||||
.filter_map(|(entity, alias)| {
|
||||
let entity = entity.trim();
|
||||
let alias = alias.trim();
|
||||
if entity.is_empty() || alias.is_empty() { return None; }
|
||||
Some((entity.chars().take(160).collect::<String>(), alias.chars().take(80).collect::<String>()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) {
|
||||
let default_entity = settings.home_assistant.default_entity_id.clone();
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) {
|
||||
settings.home_assistant.default_entity_id = entity_id;
|
||||
}
|
||||
let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone();
|
||||
if !outdoor_entity.trim().is_empty() {
|
||||
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) {
|
||||
settings.home_assistant.outdoor_entity_id = entity_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
|
||||
let Some(configured) = zone.ha_entity_id.clone() else { return; };
|
||||
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
|
||||
}
|
||||
|
||||
fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
|
||||
for mut zone in state.db.list_zones()? {
|
||||
let previous = zone.ha_entity_id.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, settings);
|
||||
if zone.ha_entity_id != previous {
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
|
||||
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
|
||||
NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
|
||||
settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut export = state.db.export_configuration(settings)?;
|
||||
// Backups are configuration snapshots, not a way to resurrect transient ownership,
|
||||
// timers or a stale physical device state after restore (K9).
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
Ok(Json(export))
|
||||
}
|
||||
|
||||
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()))?;
|
||||
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
|
||||
}
|
||||
|
||||
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();
|
||||
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
|
||||
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
|
||||
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|
||||
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len()
|
||||
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("")
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
|
||||
}
|
||||
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
|
||||
if device_macs.len() != export.devices.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
let mut zone_devices = std::collections::HashSet::new();
|
||||
for zone in &export.zones {
|
||||
if !zone_devices.insert(zone.device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
|
||||
}
|
||||
if !matches!(zone.mode.as_str(), "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
|
||||
}
|
||||
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
|
||||
return Err(AppError::BadRequest("import contains an invalid zone sensor source".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()));
|
||||
}
|
||||
for item in &export.schedules {
|
||||
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
|
||||
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
|
||||
}
|
||||
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
|
||||
NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
|
||||
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
|
||||
}
|
||||
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
|
||||
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
|
||||
}
|
||||
}
|
||||
validate_schedule_set(&export.schedules)?;
|
||||
|
||||
if export.groups.iter().any(|group| {
|
||||
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
|
||||
group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len()
|
||||
|| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))
|
||||
}) {
|
||||
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
|
||||
}
|
||||
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
|
||||
if groups.len() != export.groups.len() {
|
||||
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
|
||||
}
|
||||
|
||||
for item in &export.automations {
|
||||
match item.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
|
||||
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
|
||||
};
|
||||
if !devices.contains(trigger_id) || item.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
|
||||
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
|
||||
}
|
||||
|
||||
if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
|
||||
if !groups.contains(group_id) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
|
||||
}
|
||||
if let Some(mode) = item.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
|
||||
}
|
||||
}
|
||||
if let Some(preset) = item.action_preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
|
||||
}
|
||||
}
|
||||
if item.action.target_temperature.is_some() || item.action.fan_speed.is_some()
|
||||
|| item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|
||||
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|
||||
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into()));
|
||||
}
|
||||
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
|
||||
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
|
||||
}
|
||||
} else {
|
||||
if !devices.contains(item.action_device_id.as_str()) {
|
||||
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
|
||||
}
|
||||
engine::validate_command(&item.action)?;
|
||||
if item.action.is_empty() {
|
||||
return Err(AppError::BadRequest("import contains an empty automation action".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
|
||||
let now = Utc::now();
|
||||
for device in &mut export.devices {
|
||||
device.power = false;
|
||||
device.mode = "cool".into();
|
||||
device.target_temperature = 23.0;
|
||||
device.fan_speed = 0;
|
||||
device.swing_vertical = false;
|
||||
device.swing_horizontal = false;
|
||||
device.quiet = false;
|
||||
device.turbo = false;
|
||||
device.light = false;
|
||||
device.air = false;
|
||||
device.xfan = false;
|
||||
device.health = false;
|
||||
device.sleep = false;
|
||||
device.current_temperature = None;
|
||||
device.outdoor_temperature = None;
|
||||
device.online = false;
|
||||
device.response_time_ms = None;
|
||||
device.last_seen = None;
|
||||
device.last_error = None;
|
||||
device.communication_failures = 0;
|
||||
device.updated_at = now;
|
||||
}
|
||||
for zone in &mut export.zones {
|
||||
zone.device_temperature = None;
|
||||
zone.external_temperature = None;
|
||||
zone.current_temperature = None;
|
||||
zone.control_temperature_source = "device".into();
|
||||
zone.active_preset = "comfort".into();
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.temporary_quick_thermostat = None;
|
||||
zone.device_manual_override = false;
|
||||
zone.device_manual_override_since = None;
|
||||
zone.device_manual_override_until = None;
|
||||
zone.device_manual_override_fields.clear();
|
||||
zone.device_manual_override_baseline = None;
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = None;
|
||||
zone.control_resume_at = None;
|
||||
zone.control_reason = "Imported configuration; runtime ownership reset".into();
|
||||
zone.last_power_change_at = None;
|
||||
zone.last_mode_change_at = None;
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
zone.effective_mode.clear();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
zone.last_action_at = None;
|
||||
zone.revision = 0;
|
||||
zone.updated_at = now;
|
||||
}
|
||||
for automation in &mut export.automations {
|
||||
automation.last_fired_at = None;
|
||||
automation.updated_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
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.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
|
||||
normalize_sensor_aliases(&mut export.settings);
|
||||
canonicalize_home_assistant_entities(&mut export.settings);
|
||||
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
|
||||
validate_night_mode(&mut export.settings)?;
|
||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
|
||||
// Before replacing ownership, safely stop every currently managed device whose zone is
|
||||
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
|
||||
// could keep running after its database owner disappears.
|
||||
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
|
||||
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
|
||||
.collect();
|
||||
let mut detach_devices = std::collections::HashSet::new();
|
||||
for current in state.db.list_zones()? {
|
||||
if imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str()) {
|
||||
detach_devices.insert(current.device_id);
|
||||
}
|
||||
}
|
||||
for device_id in detach_devices {
|
||||
ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?;
|
||||
}
|
||||
|
||||
// Configuration import never restores ephemeral owners/timers or cached physical state.
|
||||
// Imported devices are reconciled from a fresh poll and current house/group/zone gates.
|
||||
sanitize_configuration_runtime(&mut export);
|
||||
state.initial_device_sync_complete.store(false, Ordering::Release);
|
||||
state.db.replace_configuration(&export)?;
|
||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||
*state.settings.write().await = export.settings.clone();
|
||||
|
||||
let disabled_group_zones: std::collections::HashSet<String> = export.groups.iter()
|
||||
.filter(|group| !group.power_enabled)
|
||||
.flat_map(|group| group.zone_ids.iter().cloned())
|
||||
.collect();
|
||||
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
|
||||
.filter(|zone| {
|
||||
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
export.settings.house_power_enabled
|
||||
&& zone.enabled
|
||||
&& effective_mode != "off"
|
||||
&& !disabled_group_zones.contains(&zone.id)
|
||||
})
|
||||
.map(|zone| zone.device_id.clone())
|
||||
.collect();
|
||||
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
|
||||
if let Err(err) = engine::force_power_off_device(&state, &device.id).await {
|
||||
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
// Rebuild live device snapshots before allowing the thermostat loop to make decisions.
|
||||
// Network failures are represented in device health by poll_one rather than reviving
|
||||
// imported cache values.
|
||||
engine::poll_all(&state).await?;
|
||||
state.initial_device_sync_complete.store(true, Ordering::Release);
|
||||
state.wake_zone_control();
|
||||
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})))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
async fn health(State(state): State<AppState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"name": "gree-controller",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
"time": Utc::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn bootstrap(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(build_bootstrap(&state).await?))
|
||||
}
|
||||
|
||||
async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let devices = state.db.list_devices()?;
|
||||
let device_count = devices.len();
|
||||
let online_count = devices.iter().filter(|value| value.online).count();
|
||||
let simulator_count = devices.iter().filter(|value| value.simulated).count();
|
||||
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
|
||||
Ok(json!({
|
||||
"devices": devices,
|
||||
"zones": state.db.list_zones()?,
|
||||
"groups": state.db.list_groups()?,
|
||||
"schedules": state.db.list_schedules()?,
|
||||
"automations": state.db.list_automations()?,
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
"settings": public_settings(&settings),
|
||||
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
||||
"system": {
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
"auth_required": !state.config.app_token.trim().is_empty(),
|
||||
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
"database": state.config.database.display().to_string(),
|
||||
"device_count": device_count,
|
||||
"online_count": online_count,
|
||||
"simulator_count": simulator_count,
|
||||
"bind": state.config.bind.to_string(),
|
||||
"base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() },
|
||||
"gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() },
|
||||
"gree_received_frames": received_frames_total,
|
||||
"gree_received_frames_by_device": received_frames_by_device,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let devices = state.db.list_devices()?;
|
||||
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
|
||||
Ok(Json(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
"database": state.config.database.display().to_string(),
|
||||
"device_count": devices.len(),
|
||||
"online_count": devices.iter().filter(|v| v.online).count(),
|
||||
"simulator_count": devices.iter().filter(|v| v.simulated).count(),
|
||||
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
"auth_required": !state.config.app_token.trim().is_empty(),
|
||||
"bind": state.config.bind.to_string(),
|
||||
"base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() },
|
||||
"gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() },
|
||||
"gree_received_frames": received_frames_total,
|
||||
"gree_received_frames_by_device": received_frames_by_device,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WsQuery { token: Option<String> }
|
||||
async fn websocket(State(state): State<AppState>, Query(query): Query<WsQuery>, ws: WebSocketUpgrade) -> Result<Response, AppError> {
|
||||
let expected = state.config.app_token.trim();
|
||||
if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); }
|
||||
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
|
||||
}
|
||||
|
||||
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
let initial = match build_bootstrap(&state).await {
|
||||
Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
|
||||
Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}),
|
||||
};
|
||||
if socket.send(Message::Text(initial.to_string())).await.is_err() { return; }
|
||||
let mut receiver = state.events.subscribe();
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = receiver.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if let Ok(text) = serde_json::to_string(&event) {
|
||||
if socket.send(Message::Text(text)).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
message = socket.next() => {
|
||||
match message {
|
||||
Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } }
|
||||
Some(Ok(Message::Text(text))) if text == "ping" => { if socket.send(Message::Text("pong".into())).await.is_err() { break; } }
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ZoneInput {
|
||||
name: String,
|
||||
device_id: String,
|
||||
#[serde(default = "yes")]
|
||||
enabled: bool,
|
||||
#[serde(default = "cool")]
|
||||
mode: String,
|
||||
#[serde(default = "yes")]
|
||||
inherit_house_mode: bool,
|
||||
#[serde(default = "setpoint")]
|
||||
setpoint: f64,
|
||||
#[serde(default = "cool_comfort")]
|
||||
cool_comfort_setpoint: f64,
|
||||
#[serde(default = "cool_sleep")]
|
||||
cool_sleep_setpoint: f64,
|
||||
#[serde(default = "cool_away")]
|
||||
cool_away_setpoint: f64,
|
||||
#[serde(default = "heat_comfort")]
|
||||
heat_comfort_setpoint: f64,
|
||||
#[serde(default = "heat_sleep")]
|
||||
heat_sleep_setpoint: f64,
|
||||
#[serde(default = "heat_away")]
|
||||
heat_away_setpoint: f64,
|
||||
#[serde(default = "hysteresis")]
|
||||
hysteresis: f64,
|
||||
#[serde(default = "cycle")]
|
||||
min_on_seconds: u64,
|
||||
#[serde(default = "cycle")]
|
||||
min_off_seconds: u64,
|
||||
#[serde(default = "min_adjust")]
|
||||
min_adjust_seconds: u64,
|
||||
#[serde(default = "standby_offset")]
|
||||
standby_offset_c: f64,
|
||||
#[serde(default = "yes")]
|
||||
smart_fan: bool,
|
||||
#[serde(default = "device_source")]
|
||||
sensor_source: String,
|
||||
#[serde(default)]
|
||||
ha_entity_id: Option<String>,
|
||||
#[serde(default = "external_sensor_weight")]
|
||||
external_sensor_weight: f64,
|
||||
#[serde(default = "max_sensor_difference")]
|
||||
max_sensor_difference: f64,
|
||||
#[serde(default = "sensor_stale_after")]
|
||||
sensor_stale_after_seconds: u64,
|
||||
#[serde(default)]
|
||||
revision: Option<u64>,
|
||||
}
|
||||
fn yes() -> bool { true }
|
||||
fn cool() -> String { "cool".into() }
|
||||
fn setpoint() -> f64 { 24.0 }
|
||||
fn cool_comfort() -> f64 { 23.0 }
|
||||
fn cool_sleep() -> f64 { 24.5 }
|
||||
fn cool_away() -> f64 { 27.0 }
|
||||
fn heat_comfort() -> f64 { 21.0 }
|
||||
fn heat_sleep() -> f64 { 19.0 }
|
||||
fn heat_away() -> f64 { 17.0 }
|
||||
fn hysteresis() -> f64 { 0.6 }
|
||||
fn cycle() -> u64 { 180 }
|
||||
fn min_adjust() -> u64 { 120 }
|
||||
fn standby_offset() -> f64 { 2.0 }
|
||||
fn external_sensor_weight() -> f64 { 0.4 }
|
||||
fn max_sensor_difference() -> f64 { 3.0 }
|
||||
fn sensor_stale_after() -> u64 { 300 }
|
||||
fn device_source() -> String { "device".into() }
|
||||
|
||||
impl ZoneInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); }
|
||||
for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint,
|
||||
self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); }
|
||||
}
|
||||
if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); }
|
||||
if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); }
|
||||
if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
|
||||
if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); }
|
||||
if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); }
|
||||
if !(0.1..=20.0).contains(&self.max_sensor_difference) { return Err(AppError::BadRequest("maximum sensor difference must be between 0.1 and 20 C".into())); }
|
||||
if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") && self.ha_entity_id.as_deref().map(|value| value.trim()).unwrap_or("").is_empty() {
|
||||
return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn into_zone(self, id: String, created_at: chrono::DateTime<Utc>) -> Zone {
|
||||
Zone {
|
||||
id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled,
|
||||
mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1,
|
||||
cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint,
|
||||
cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint,
|
||||
heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint,
|
||||
hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
|
||||
min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan,
|
||||
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
||||
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds,
|
||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None,
|
||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(),
|
||||
last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
|
||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
|
||||
created_at, updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> {
|
||||
if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) {
|
||||
return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_zones(State(state): State<AppState>) -> Result<Json<Vec<Zone>>, AppError> { Ok(Json(state.db.list_zones()?)) }
|
||||
async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Zone>, AppError> {
|
||||
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
|
||||
}
|
||||
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
|
||||
input.validate()?;
|
||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||
validate_zone_device_assignment(&state, &input.device_id, None)?;
|
||||
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
||||
let settings = state.settings.read().await.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.created", serde_json::to_value(&zone)?);
|
||||
Ok((StatusCode::CREATED, Json(zone)))
|
||||
}
|
||||
async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
|
||||
input.validate()?;
|
||||
let _zone_guard = state.lock_zone_operation(&id).await;
|
||||
let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
if let Some(expected) = input.revision {
|
||||
if expected != existing.revision {
|
||||
return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision)));
|
||||
}
|
||||
}
|
||||
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
|
||||
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
|
||||
let device_changed = existing.device_id != input.device_id;
|
||||
// Serialize a normal zone edit with polling/manual-takeover detection for its device.
|
||||
// Device reassignment uses ensure_device_stopped_for_detach below, which acquires the
|
||||
// old device lock itself while this zone lock is held.
|
||||
let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None };
|
||||
if !device_changed {
|
||||
// Polling may have updated takeover/runtime state while we were waiting for the
|
||||
// device lock. Re-read under both locks before building the replacement Zone.
|
||||
existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
if let Some(expected) = input.revision {
|
||||
if expected != existing.revision {
|
||||
return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut zone = input.into_zone(id, existing.created_at);
|
||||
if !device_changed {
|
||||
zone.device_temperature = existing.device_temperature;
|
||||
zone.external_temperature = existing.external_temperature;
|
||||
zone.current_temperature = existing.current_temperature;
|
||||
zone.control_temperature_source = existing.control_temperature_source;
|
||||
zone.active_preset = existing.active_preset;
|
||||
zone.manual_preset = existing.manual_preset;
|
||||
zone.manual_setpoint = existing.manual_setpoint;
|
||||
zone.manual_override_until = existing.manual_override_until;
|
||||
zone.local_thermostat_power = existing.local_thermostat_power;
|
||||
zone.local_thermostat_resume_at = existing.local_thermostat_resume_at;
|
||||
zone.local_thermostat_restore_zone_enabled = existing.local_thermostat_restore_zone_enabled;
|
||||
zone.temporary_quick_thermostat = existing.temporary_quick_thermostat;
|
||||
zone.device_manual_override = existing.device_manual_override;
|
||||
zone.device_manual_override_since = existing.device_manual_override_since;
|
||||
zone.device_manual_override_until = existing.device_manual_override_until;
|
||||
zone.device_manual_override_fields = existing.device_manual_override_fields;
|
||||
zone.device_manual_override_baseline = existing.device_manual_override_baseline;
|
||||
zone.revision = existing.revision.saturating_add(1);
|
||||
zone.control_owner = existing.control_owner;
|
||||
zone.control_source = existing.control_source;
|
||||
zone.control_since = existing.control_since;
|
||||
zone.control_resume_at = existing.control_resume_at;
|
||||
zone.control_reason = existing.control_reason;
|
||||
zone.last_power_change_at = existing.last_power_change_at;
|
||||
zone.last_mode_change_at = existing.last_mode_change_at;
|
||||
zone.lockout_until = existing.lockout_until;
|
||||
zone.lockout_reason = existing.lockout_reason;
|
||||
zone.effective_mode = existing.effective_mode;
|
||||
zone.effective_setpoint = existing.effective_setpoint;
|
||||
zone.device_setpoint = existing.device_setpoint;
|
||||
zone.demand = existing.demand;
|
||||
zone.demand_since = existing.demand_since;
|
||||
zone.target_alerted_at = existing.target_alerted_at;
|
||||
zone.last_action_at = existing.last_action_at;
|
||||
} else {
|
||||
// A new physical unit starts with a clean ownership/runtime state. Never transfer
|
||||
// demand, sensor cache or remote-control takeover from the previous device.
|
||||
ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?;
|
||||
zone.revision = existing.revision.saturating_add(1);
|
||||
}
|
||||
let settings = state.settings.read().await.clone();
|
||||
canonicalize_zone_ha_entity(&mut zone, &settings);
|
||||
let power_off_device = !device_changed && existing.enabled && !zone.enabled;
|
||||
if power_off_device {
|
||||
// Full configuration PUT and quick-control disable use the same ownership cleanup.
|
||||
// No local/temporary/manual takeover survives a disabled thermostat zone (H6).
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
engine::finish_temporary_quick_thermostat(&mut zone, &state.db.list_schedules()?, &settings.house_mode);
|
||||
} else {
|
||||
zone.temporary_quick_thermostat = None;
|
||||
}
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
engine::reset_device_manual_override(&mut zone);
|
||||
zone.enabled = false;
|
||||
}
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
drop(_device_guard);
|
||||
if power_off_device {
|
||||
power_off_zone_device(&state, &zone, "zone.disabled").await;
|
||||
}
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result<Zone, AppError> {
|
||||
// Serialize quick-thermostat changes with the same device lock used by GREE polling and
|
||||
// manual-takeover detection. Without this, a poll that started just before a Web/HA
|
||||
// thermostat action could save an older zone snapshot afterwards and resurrect a false
|
||||
// "physical/pilot" takeover.
|
||||
let _zone_guard = state.lock_zone_operation(id).await;
|
||||
let device_id = state.db.get_zone(id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?
|
||||
.device_id;
|
||||
let device_guard = state.lock_device_operation(&device_id).await;
|
||||
let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let was_enabled = zone.enabled;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
|
||||
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
|
||||
let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false);
|
||||
if patch.temporary_quick_thermostat.is_some() && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) {
|
||||
return Err(AppError::BadRequest("temporary thermostat cannot be combined with local power/clear operations in one request".into()));
|
||||
}
|
||||
// Direct/manual device takeover is higher priority than a temporary thermostat. Creating
|
||||
// or editing a temporary session therefore never clears an active pilot/Devices takeover;
|
||||
// the session waits/pauses instead. Other explicit thermostat actions still resume control.
|
||||
let resume_device_automation = resume_device_takeover
|
||||
|| patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|
||||
|| patch.preset.is_some() || patch.enabled.is_some();
|
||||
if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() {
|
||||
zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() };
|
||||
}
|
||||
|
||||
if resume_local_thermostat {
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
}
|
||||
if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() {
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
|
||||
} else {
|
||||
// Cancelling a delayed session before it starts must not erase unrelated
|
||||
// quick preset/setpoint state that automation may be using in the meantime.
|
||||
zone.temporary_quick_thermostat = None;
|
||||
}
|
||||
}
|
||||
if let Some(request) = patch.temporary_quick_thermostat.as_ref() {
|
||||
let now = Utc::now();
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let existing_session = zone.temporary_quick_thermostat.clone();
|
||||
let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now);
|
||||
let requested_start_kind = request.start_kind.as_str();
|
||||
if !matches!(requested_start_kind, "now" | "delay" | "at") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
|
||||
}
|
||||
|
||||
// Editing an already active session changes only target/finish rules. Its historical
|
||||
// start and activated_at are preserved, so delay/at sessions cannot be accidentally
|
||||
// rescheduled or rejected because their original start is now in the past.
|
||||
let (start_kind, started_at, activated_at) = if editing_active {
|
||||
let existing = existing_session.as_ref().expect("active temporary session must exist");
|
||||
(existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone())
|
||||
} else {
|
||||
let started_at = match requested_start_kind {
|
||||
"now" => now.clone(),
|
||||
"delay" => {
|
||||
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
|
||||
if !(1..=43_200).contains(&minutes) {
|
||||
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
|
||||
}
|
||||
now.clone() + ChronoDuration::minutes(minutes as i64)
|
||||
}
|
||||
"at" => {
|
||||
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
|
||||
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
|
||||
if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
|
||||
at
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
(requested_start_kind.to_string(), started_at, None)
|
||||
};
|
||||
|
||||
if !editing_active && start_kind == "now" && !runtime.house_power_enabled {
|
||||
return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into()));
|
||||
}
|
||||
|
||||
let finish_kind = request.finish_kind.as_str();
|
||||
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
|
||||
}
|
||||
|
||||
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
|
||||
if !(8.0..=30.0).contains(&target) {
|
||||
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
|
||||
}
|
||||
let target = (target * 2.0).round() / 2.0;
|
||||
let min_stable_tolerance = (zone.hysteresis.max(0.1) / 2.0 + 0.1).min(3.0);
|
||||
let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3));
|
||||
if !(0.1..=3.0).contains(&requested_tolerance) {
|
||||
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
|
||||
}
|
||||
let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance };
|
||||
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
|
||||
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
|
||||
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
|
||||
}
|
||||
|
||||
let duration_seconds = if finish_kind == "duration" {
|
||||
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
|
||||
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
|
||||
Some(minutes.saturating_mul(60))
|
||||
} else { None };
|
||||
let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable");
|
||||
let hold_seconds = if finish_kind == "temperature_stable" {
|
||||
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
|
||||
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
|
||||
minutes.saturating_mul(60)
|
||||
} else { 0 };
|
||||
let safety_duration_seconds = if is_temperature_condition {
|
||||
request.max_duration_minutes.map(|minutes| {
|
||||
if !(1..=14_400).contains(&minutes) {
|
||||
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
|
||||
}
|
||||
Ok(minutes.saturating_mul(60))
|
||||
}).transpose()?
|
||||
} else { None };
|
||||
|
||||
let active_base = activated_at.clone().unwrap_or(now.clone());
|
||||
let expires_at = match finish_kind {
|
||||
"duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None },
|
||||
"until" => {
|
||||
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
|
||||
let comparison_start = if editing_active { now.clone() } else { started_at.clone() };
|
||||
if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); }
|
||||
if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
|
||||
Some(until)
|
||||
}
|
||||
"schedule_boundary" => {
|
||||
let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) };
|
||||
Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference)
|
||||
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let safety_expires_at = if editing_active {
|
||||
safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64))
|
||||
} else { None };
|
||||
|
||||
let starts_now = start_kind == "now";
|
||||
let immediate_activation = !editing_active && starts_now && runtime.house_power_enabled && !zone.device_manual_override;
|
||||
let restore_zone_enabled = if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
|
||||
} else if immediate_activation {
|
||||
Some(zone.enabled)
|
||||
} else {
|
||||
// Delayed sessions capture this at actual takeover time (H12), not planning time.
|
||||
None
|
||||
};
|
||||
let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() };
|
||||
let active_mode = if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.active_mode.clone())
|
||||
} else if immediate_activation {
|
||||
Some(captured_mode.clone())
|
||||
} else { None };
|
||||
let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str());
|
||||
if is_temperature_condition {
|
||||
if (condition_mode == "heat" && temperature_operator == "at_or_below")
|
||||
|| (condition_mode == "cool" && temperature_operator == "at_or_above")
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let state_value = if editing_active {
|
||||
if zone.device_manual_override { "paused_manual" } else { "active" }
|
||||
} else if starts_now && zone.device_manual_override {
|
||||
"paused_manual"
|
||||
} else {
|
||||
"scheduled"
|
||||
};
|
||||
let underlying_local_power = zone.local_thermostat_power;
|
||||
let underlying_local_resume_at = zone.local_thermostat_resume_at;
|
||||
let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
|
||||
let underlying_manual_preset = zone.manual_preset.clone();
|
||||
let underlying_manual_setpoint = zone.manual_setpoint;
|
||||
let underlying_manual_override_until = zone.manual_override_until;
|
||||
|
||||
if immediate_activation {
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.enabled = true;
|
||||
zone.manual_setpoint = Some(target);
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.manual_override_until = None;
|
||||
} else if editing_active {
|
||||
// Keep current ownership and update the live target without restarting the session.
|
||||
zone.manual_setpoint = Some(target);
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
|
||||
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
|
||||
start_kind,
|
||||
finish_kind: finish_kind.into(),
|
||||
started_at,
|
||||
activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() },
|
||||
state: state_value.into(),
|
||||
active_mode,
|
||||
restore_zone_enabled,
|
||||
restore_local_thermostat_power: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power)
|
||||
} else if immediate_activation { underlying_local_power } else { None },
|
||||
restore_local_thermostat_resume_at: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at)
|
||||
} else if immediate_activation { underlying_local_resume_at } else { None },
|
||||
restore_local_thermostat_zone_enabled: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled)
|
||||
} else if immediate_activation { underlying_local_zone_enabled } else { None },
|
||||
restore_manual_preset: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone())
|
||||
} else if immediate_activation { underlying_manual_preset } else { None },
|
||||
restore_manual_setpoint: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_manual_setpoint)
|
||||
} else if immediate_activation { underlying_manual_setpoint } else { None },
|
||||
restore_manual_override_until: if editing_active {
|
||||
existing_session.as_ref().and_then(|session| session.restore_manual_override_until)
|
||||
} else if immediate_activation { underlying_manual_override_until } else { None },
|
||||
expires_at: if immediate_activation && finish_kind == "duration" {
|
||||
duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
|
||||
} else { expires_at },
|
||||
duration_seconds,
|
||||
safety_duration_seconds,
|
||||
temperature_target: Some(target),
|
||||
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
|
||||
tolerance_c: tolerance,
|
||||
hold_seconds,
|
||||
condition_started_at: None,
|
||||
condition_last_observed_at: None,
|
||||
paused_at: if zone.device_manual_override && (editing_active || starts_now) {
|
||||
existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone()))
|
||||
} else { None },
|
||||
deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()),
|
||||
deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()),
|
||||
safety_expires_at: if immediate_activation && is_temperature_condition {
|
||||
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
|
||||
} else { safety_expires_at },
|
||||
});
|
||||
}
|
||||
if let Some(power) = patch.power {
|
||||
// The neighbouring quick-power control and the explicit Stop button must use the
|
||||
// same temporary-session cleanup/restore semantics before local ownership changes.
|
||||
if zone.temporary_quick_thermostat.is_some() {
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
|
||||
} else {
|
||||
zone.temporary_quick_thermostat = None;
|
||||
}
|
||||
}
|
||||
engine::set_local_thermostat_power(&mut zone, power, Utc::now());
|
||||
if power { zone.enabled = true; }
|
||||
// A manually started local thermostat keeps an already selected target/profile until
|
||||
// it is switched off and the delayed hand-back completes, Auto is selected, or the
|
||||
// user explicitly resumes automation.
|
||||
if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) {
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
}
|
||||
if let Some(value) = patch.setpoint {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
||||
let value = (value * 2.0).round() / 2.0;
|
||||
zone.setpoint = value;
|
||||
zone.manual_setpoint = Some(value);
|
||||
zone.effective_setpoint = Some(value);
|
||||
if zone.local_thermostat_power == Some(true) {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
// +/- always edits the live temporary target, including duration/until
|
||||
// sessions, so the modal and regulator cannot diverge (M14).
|
||||
session.temperature_target = Some(value);
|
||||
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
|
||||
None
|
||||
} else {
|
||||
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
|
||||
};
|
||||
}
|
||||
if let Some(value) = patch.mode.as_deref() {
|
||||
if !matches!(value, "house" | "auto" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("zone mode must be house, cool or heat".into()));
|
||||
}
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.deferred_mode = Some(value.to_string());
|
||||
}
|
||||
} else {
|
||||
match value {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = value.to_string();
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(value) = patch.preset.as_deref() {
|
||||
if !matches!(value, "auto" | "comfort" | "sleep" | "away" | "custom") {
|
||||
return Err(AppError::BadRequest("unsupported zone preset".into()));
|
||||
}
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.deferred_preset = Some(value.to_string());
|
||||
}
|
||||
} else {
|
||||
match value {
|
||||
"auto" => {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
"comfort" | "sleep" | "away" | "custom" => {
|
||||
zone.manual_preset = Some(value.to_string());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
|
||||
None
|
||||
} else {
|
||||
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
|
||||
};
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.clear_override.unwrap_or(false) {
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.deferred_preset = Some("auto".into());
|
||||
}
|
||||
} else {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
}
|
||||
if let Some(value) = patch.enabled {
|
||||
if !value {
|
||||
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
|
||||
engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode);
|
||||
} else {
|
||||
zone.temporary_quick_thermostat = None;
|
||||
}
|
||||
engine::reset_local_thermostat_override(&mut zone);
|
||||
engine::reset_device_manual_override(&mut zone);
|
||||
zone.enabled = false;
|
||||
} else {
|
||||
zone.enabled = true;
|
||||
}
|
||||
}
|
||||
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let house_mode = runtime.house_mode.clone();
|
||||
let blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group);
|
||||
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
// Release before any device command below; engine::send_command acquires this same lock.
|
||||
drop(device_guard);
|
||||
if was_enabled && !zone.enabled {
|
||||
power_off_zone_device(state, &zone, "zone.quick_disabled").await;
|
||||
} else if patch.power == Some(false) {
|
||||
if let Err(err) = engine::send_command(
|
||||
state,
|
||||
&zone.device_id,
|
||||
DeviceCommand { power: Some(false), ..Default::default() },
|
||||
).await {
|
||||
state.log("error", "zone.local_power_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "power": false
|
||||
}));
|
||||
}
|
||||
state.wake_zone_control();
|
||||
} else {
|
||||
state.wake_zone_control();
|
||||
}
|
||||
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
||||
"override_until": zone.manual_override_until, "enabled": zone.enabled,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"temporary_quick_thermostat": zone.temporary_quick_thermostat,
|
||||
"device_manual_override_cleared": device_override_cleared
|
||||
}));
|
||||
Ok(zone)
|
||||
}
|
||||
|
||||
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
||||
Ok(Json(apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?))
|
||||
}
|
||||
|
||||
|
||||
|
||||
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
|
||||
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
|
||||
if !device.enabled {
|
||||
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
|
||||
}
|
||||
// Force one OFF transition even when the cached state already says OFF. A remote change
|
||||
// may not have been polled yet and detaching must not leave a running unit without owner.
|
||||
engine::force_power_off_device(state, device_id).await?;
|
||||
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
|
||||
"device_id": device.id, "source": source
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
|
||||
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
|
||||
if !device.enabled { return; }
|
||||
if let Err(err) = engine::force_power_off_device(state, &device.id).await {
|
||||
state.log("error", "zone.disable_power_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"source": source,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,659 +14,14 @@ pub struct Db {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("cannot open SQLite database {}", path.display()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
conn.execute_batch(queries::INIT_SCHEMA)?;
|
||||
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
|
||||
self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned"))
|
||||
}
|
||||
|
||||
fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> {
|
||||
Ok(serde_json::from_str(&payload)?)
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: &T) -> Result<String> {
|
||||
Ok(serde_json::to_string(value)?)
|
||||
}
|
||||
|
||||
pub fn count_devices(&self) -> Result<u64> {
|
||||
let conn = self.lock()?;
|
||||
let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?;
|
||||
Ok(count.max(0) as u64)
|
||||
}
|
||||
|
||||
pub fn save_device(&self, device: &Device) -> Result<()> {
|
||||
let payload = Self::to_json(device)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_DEVICE,
|
||||
params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_devices(&self) -> Result<Vec<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICES)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
pub fn get_device(&self, id: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn delete_device(&self, id: &str) -> Result<bool> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn save_zone(&self, zone: &Zone) -> Result<()> {
|
||||
let payload = Self::to_json(zone)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_ZONE,
|
||||
params![zone.id, payload, zone.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_zones(&self) -> Result<Vec<Zone>> {
|
||||
self.list_payloads(queries::LIST_ZONES)
|
||||
}
|
||||
|
||||
pub fn get_zone(&self, id: &str) -> Result<Option<Zone>> {
|
||||
self.get_payload(queries::GET_ZONE, id)
|
||||
}
|
||||
|
||||
pub fn delete_zone(&self, id: &str) -> Result<bool> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_ZONE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn save_group(&self, group: &ClimateGroup) -> Result<()> {
|
||||
let payload = Self::to_json(group)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_groups(&self) -> Result<Vec<ClimateGroup>> {
|
||||
self.list_payloads(queries::LIST_GROUPS)
|
||||
}
|
||||
|
||||
pub fn get_group(&self, id: &str) -> Result<Option<ClimateGroup>> {
|
||||
self.get_payload(queries::GET_GROUP, id)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("groups", id)
|
||||
}
|
||||
|
||||
pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_schedules(&self) -> Result<Vec<Schedule>> {
|
||||
self.list_payloads(queries::LIST_SCHEDULES)
|
||||
}
|
||||
|
||||
pub fn get_schedule(&self, id: &str) -> Result<Option<Schedule>> {
|
||||
self.get_payload(queries::GET_SCHEDULE, id)
|
||||
}
|
||||
|
||||
pub fn delete_schedule(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("schedules", id)
|
||||
}
|
||||
|
||||
pub fn replace_schedules_for_zone(&self, zone_id: &str, schedules: &[Schedule]) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [zone_id])?;
|
||||
for schedule in schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, item: &Automation) -> Result<()> {
|
||||
let payload = Self::to_json(item)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_AUTOMATION,
|
||||
params![item.id, payload, item.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_automations(&self) -> Result<Vec<Automation>> {
|
||||
self.list_payloads(queries::LIST_AUTOMATIONS)
|
||||
}
|
||||
|
||||
pub fn get_automation(&self, id: &str) -> Result<Option<Automation>> {
|
||||
self.get_payload(queries::GET_AUTOMATION, id)
|
||||
}
|
||||
|
||||
pub fn delete_automation(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("automations", id)
|
||||
}
|
||||
|
||||
fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
fn get_payload<T: DeserializeOwned>(&self, sql: &str, id: &str) -> Result<Option<T>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(sql, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
fn delete_by_id(&self, table: &str, id: &str) -> Result<bool> {
|
||||
let sql = match table {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
"groups" => queries::DELETE_GROUP,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(sql, [id])? > 0)
|
||||
}
|
||||
|
||||
pub fn add_reading(&self, reading: &Reading) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_READING,
|
||||
params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature,
|
||||
reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_readings(&self, device_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit.clamp(1, 5000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reading> {
|
||||
let timestamp: String = row.get(2)?;
|
||||
Ok(Reading {
|
||||
id: row.get(0)?,
|
||||
device_id: row.get(1)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
indoor_temperature: row.get(3)?,
|
||||
outdoor_temperature: row.get(4)?,
|
||||
target_temperature: row.get(5)?,
|
||||
power: row.get::<_, i64>(6)? != 0,
|
||||
source: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
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()?;
|
||||
let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
let ha = conn.execute(queries::PRUNE_HA_READINGS, [before.to_rfc3339()])? as u64;
|
||||
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()?;
|
||||
let changed = conn.execute(
|
||||
queries::INSERT_ZONE_READING_IF_DUE,
|
||||
params![
|
||||
reading.zone_id,
|
||||
reading.device_id,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.gree_temperature,
|
||||
reading.external_temperature,
|
||||
reading.control_temperature,
|
||||
reading.target_temperature,
|
||||
reading.device_setpoint,
|
||||
reading.outdoor_temperature,
|
||||
reading.power as i64,
|
||||
reading.mode,
|
||||
reading.fan_speed as i64,
|
||||
reading.demand as i64,
|
||||
reading.control_source,
|
||||
reading.active_preset,
|
||||
cutoff.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<ZoneReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(zone_id) = zone_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_zone_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<ZoneReading> {
|
||||
let timestamp: String = row.get(3)?;
|
||||
Ok(ZoneReading {
|
||||
id: row.get(0)?,
|
||||
zone_id: row.get(1)?,
|
||||
device_id: row.get(2)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
gree_temperature: row.get(4)?,
|
||||
external_temperature: row.get(5)?,
|
||||
control_temperature: row.get(6)?,
|
||||
target_temperature: row.get(7)?,
|
||||
device_setpoint: row.get(8)?,
|
||||
outdoor_temperature: row.get(9)?,
|
||||
power: row.get::<_, i64>(10)? != 0,
|
||||
mode: row.get(11)?,
|
||||
fan_speed: row.get::<_, i64>(12)?.clamp(0, 255) as u8,
|
||||
demand: row.get::<_, i64>(13)? != 0,
|
||||
control_source: row.get(14)?,
|
||||
active_preset: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
queries::INSERT_HA_READING_IF_DUE,
|
||||
params![
|
||||
reading.entity_id,
|
||||
reading.zone_id,
|
||||
reading.kind,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.temperature,
|
||||
cutoff.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<HaReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(entity_id) = entity_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_ha_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<HaReading> {
|
||||
let timestamp: String = row.get(4)?;
|
||||
Ok(HaReading {
|
||||
id: row.get(0)?,
|
||||
entity_id: row.get(1)?,
|
||||
zone_id: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
temperature: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn history_counts(&self) -> Result<(u64, u64, u64)> {
|
||||
let conn = self.lock()?;
|
||||
let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64))
|
||||
}
|
||||
|
||||
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_EVENT,
|
||||
params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_events(&self, limit: u32) -> Result<Vec<EventLog>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_EVENTS)?;
|
||||
let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| {
|
||||
let ts: String = row.get(1)?;
|
||||
let metadata: String = row.get(5)?;
|
||||
Ok(EventLog {
|
||||
id: row.get(0)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()),
|
||||
level: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
message: row.get(4)?,
|
||||
metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prune_events(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::PRUNE_EVENTS, [before.to_rfc3339()])? as u64)
|
||||
}
|
||||
|
||||
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let created_at: String = row.get(3)?;
|
||||
Ok(ApiTokenInfo {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
token_prefix: row.get(2)?,
|
||||
created_at: DateTime::parse_from_rfc3339(&created_at)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_API_TOKEN,
|
||||
params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
let found: Option<i64> = conn.query_row(
|
||||
queries::API_TOKEN_EXISTS,
|
||||
[token_hash],
|
||||
|row| row.get(0),
|
||||
).optional()?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
pub fn delete_api_token(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
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()?,
|
||||
groups: self.list_groups()?,
|
||||
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 group in &export.groups {
|
||||
let payload = Self::to_json(group)?;
|
||||
tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.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()?;
|
||||
value.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> {
|
||||
let value = Self::to_json(settings)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_RUNTIME_SETTINGS,
|
||||
params![value, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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();
|
||||
let db = Db::open(&dir.path().join("test.db")).unwrap();
|
||||
let device = Device::simulated_default();
|
||||
db.save_device(&device).unwrap();
|
||||
let loaded = db.get_device(&device.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.mac, device.mac);
|
||||
assert_eq!(db.list_devices().unwrap().len(), 1);
|
||||
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
{
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap();
|
||||
}
|
||||
assert_eq!(db.prune_events(30).unwrap(), 1);
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
|
||||
let access_token = ApiTokenInfo {
|
||||
id: "token-1".into(),
|
||||
name: "Home Assistant".into(),
|
||||
token_prefix: "gree_controller_test...".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.save_api_token(&access_token, "test-hash").unwrap();
|
||||
assert!(db.api_token_exists("test-hash").unwrap());
|
||||
assert_eq!(db.list_api_tokens().unwrap().len(), 1);
|
||||
assert!(db.delete_api_token(&access_token.id).unwrap());
|
||||
assert!(!db.api_token_exists("test-hash").unwrap());
|
||||
|
||||
let now = Utc::now();
|
||||
db.add_reading(&Reading {
|
||||
id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5),
|
||||
outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(),
|
||||
}).unwrap();
|
||||
assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
|
||||
db.add_ha_reading_if_due(&HaReading {
|
||||
id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(),
|
||||
timestamp: now.clone(), temperature: 22.1,
|
||||
}, 15).unwrap();
|
||||
assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
|
||||
}
|
||||
}
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("db/core_devices.rs");
|
||||
include!("db/climate.rs");
|
||||
include!("db/schedules_automations.rs");
|
||||
include!("db/device_history.rs");
|
||||
include!("db/zone_history.rs");
|
||||
include!("db/ha_history.rs");
|
||||
include!("db/events_tokens.rs");
|
||||
include!("db/configuration.rs");
|
||||
include!("db/tests.rs");
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
impl Db {
|
||||
pub fn save_zone(&self, zone: &Zone) -> Result<()> {
|
||||
let payload = Self::to_json(zone)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_ZONE,
|
||||
params![zone.id, payload, zone.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_zones(&self) -> Result<Vec<Zone>> {
|
||||
self.list_payloads(queries::LIST_ZONES)
|
||||
}
|
||||
|
||||
pub fn get_zone(&self, id: &str) -> Result<Option<Zone>> {
|
||||
self.get_payload(queries::GET_ZONE, id)
|
||||
}
|
||||
|
||||
pub fn delete_zone(&self, id: &str) -> Result<bool> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_ZONE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn save_group(&self, group: &ClimateGroup) -> Result<()> {
|
||||
let payload = Self::to_json(group)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_groups(&self) -> Result<Vec<ClimateGroup>> {
|
||||
self.list_payloads(queries::LIST_GROUPS)
|
||||
}
|
||||
|
||||
pub fn get_group(&self, id: &str) -> Result<Option<ClimateGroup>> {
|
||||
self.get_payload(queries::GET_GROUP, id)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("groups", id)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
impl Db {
|
||||
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()?,
|
||||
groups: self.list_groups()?,
|
||||
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 group in &export.groups {
|
||||
let payload = Self::to_json(group)?;
|
||||
tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.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()?;
|
||||
value.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> {
|
||||
let value = Self::to_json(settings)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_RUNTIME_SETTINGS,
|
||||
params![value, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
impl Db {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("cannot open SQLite database {}", path.display()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
conn.execute_batch(queries::INIT_SCHEMA)?;
|
||||
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
|
||||
self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned"))
|
||||
}
|
||||
|
||||
fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> {
|
||||
Ok(serde_json::from_str(&payload)?)
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: &T) -> Result<String> {
|
||||
Ok(serde_json::to_string(value)?)
|
||||
}
|
||||
|
||||
pub fn count_devices(&self) -> Result<u64> {
|
||||
let conn = self.lock()?;
|
||||
let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?;
|
||||
Ok(count.max(0) as u64)
|
||||
}
|
||||
|
||||
pub fn save_device(&self, device: &Device) -> Result<()> {
|
||||
let payload = Self::to_json(device)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_DEVICE,
|
||||
params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_devices(&self) -> Result<Vec<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICES)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
pub fn get_device(&self, id: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn delete_device(&self, id: &str) -> Result<bool> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
impl Db {
|
||||
pub fn add_reading(&self, reading: &Reading) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_READING,
|
||||
params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature,
|
||||
reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_readings(&self, device_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit.clamp(1, 5000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reading> {
|
||||
let timestamp: String = row.get(2)?;
|
||||
Ok(Reading {
|
||||
id: row.get(0)?,
|
||||
device_id: row.get(1)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
indoor_temperature: row.get(3)?,
|
||||
outdoor_temperature: row.get(4)?,
|
||||
target_temperature: row.get(5)?,
|
||||
power: row.get::<_, i64>(6)? != 0,
|
||||
source: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
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()?;
|
||||
let device = conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
let zone = conn.execute(queries::PRUNE_ZONE_READINGS, [before.to_rfc3339()])? as u64;
|
||||
let ha = conn.execute(queries::PRUNE_HA_READINGS, [before.to_rfc3339()])? as u64;
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
impl Db {
|
||||
pub fn history_counts(&self) -> Result<(u64, u64, u64)> {
|
||||
let conn = self.lock()?;
|
||||
let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64))
|
||||
}
|
||||
|
||||
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_EVENT,
|
||||
params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_events(&self, limit: u32) -> Result<Vec<EventLog>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_EVENTS)?;
|
||||
let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| {
|
||||
let ts: String = row.get(1)?;
|
||||
let metadata: String = row.get(5)?;
|
||||
Ok(EventLog {
|
||||
id: row.get(0)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()),
|
||||
level: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
message: row.get(4)?,
|
||||
metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prune_events(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::PRUNE_EVENTS, [before.to_rfc3339()])? as u64)
|
||||
}
|
||||
|
||||
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let created_at: String = row.get(3)?;
|
||||
Ok(ApiTokenInfo {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
token_prefix: row.get(2)?,
|
||||
created_at: DateTime::parse_from_rfc3339(&created_at)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_API_TOKEN,
|
||||
params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
let found: Option<i64> = conn.query_row(
|
||||
queries::API_TOKEN_EXISTS,
|
||||
[token_hash],
|
||||
|row| row.get(0),
|
||||
).optional()?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
pub fn delete_api_token(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
impl Db {
|
||||
pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
queries::INSERT_HA_READING_IF_DUE,
|
||||
params![
|
||||
reading.entity_id,
|
||||
reading.zone_id,
|
||||
reading.kind,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.temperature,
|
||||
cutoff.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<HaReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(entity_id) = entity_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_ha_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<HaReading> {
|
||||
let timestamp: String = row.get(4)?;
|
||||
Ok(HaReading {
|
||||
id: row.get(0)?,
|
||||
entity_id: row.get(1)?,
|
||||
zone_id: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
temperature: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
impl Db {
|
||||
pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_schedules(&self) -> Result<Vec<Schedule>> {
|
||||
self.list_payloads(queries::LIST_SCHEDULES)
|
||||
}
|
||||
|
||||
pub fn get_schedule(&self, id: &str) -> Result<Option<Schedule>> {
|
||||
self.get_payload(queries::GET_SCHEDULE, id)
|
||||
}
|
||||
|
||||
pub fn delete_schedule(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("schedules", id)
|
||||
}
|
||||
|
||||
pub fn replace_schedules_for_zone(&self, zone_id: &str, schedules: &[Schedule]) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [zone_id])?;
|
||||
for schedule in schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, item: &Automation) -> Result<()> {
|
||||
let payload = Self::to_json(item)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_AUTOMATION,
|
||||
params![item.id, payload, item.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_automations(&self) -> Result<Vec<Automation>> {
|
||||
self.list_payloads(queries::LIST_AUTOMATIONS)
|
||||
}
|
||||
|
||||
pub fn get_automation(&self, id: &str) -> Result<Option<Automation>> {
|
||||
self.get_payload(queries::GET_AUTOMATION, id)
|
||||
}
|
||||
|
||||
pub fn delete_automation(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("automations", id)
|
||||
}
|
||||
|
||||
fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
fn get_payload<T: DeserializeOwned>(&self, sql: &str, id: &str) -> Result<Option<T>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(sql, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
fn delete_by_id(&self, table: &str, id: &str) -> Result<bool> {
|
||||
let sql = match table {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
"groups" => queries::DELETE_GROUP,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(sql, [id])? > 0)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#[cfg(test)]
|
||||
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();
|
||||
let db = Db::open(&dir.path().join("test.db")).unwrap();
|
||||
let device = Device::simulated_default();
|
||||
db.save_device(&device).unwrap();
|
||||
let loaded = db.get_device(&device.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.mac, device.mac);
|
||||
assert_eq!(db.list_devices().unwrap().len(), 1);
|
||||
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
{
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap();
|
||||
}
|
||||
assert_eq!(db.prune_events(30).unwrap(), 1);
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
|
||||
let access_token = ApiTokenInfo {
|
||||
id: "token-1".into(),
|
||||
name: "Home Assistant".into(),
|
||||
token_prefix: "gree_controller_test...".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.save_api_token(&access_token, "test-hash").unwrap();
|
||||
assert!(db.api_token_exists("test-hash").unwrap());
|
||||
assert_eq!(db.list_api_tokens().unwrap().len(), 1);
|
||||
assert!(db.delete_api_token(&access_token.id).unwrap());
|
||||
assert!(!db.api_token_exists("test-hash").unwrap());
|
||||
|
||||
let now = Utc::now();
|
||||
db.add_reading(&Reading {
|
||||
id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5),
|
||||
outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(),
|
||||
}).unwrap();
|
||||
assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
|
||||
db.add_ha_reading_if_due(&HaReading {
|
||||
id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(),
|
||||
timestamp: now.clone(), temperature: 22.1,
|
||||
}, 15).unwrap();
|
||||
assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
impl Db {
|
||||
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()?;
|
||||
let changed = conn.execute(
|
||||
queries::INSERT_ZONE_READING_IF_DUE,
|
||||
params![
|
||||
reading.zone_id,
|
||||
reading.device_id,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.gree_temperature,
|
||||
reading.external_temperature,
|
||||
reading.control_temperature,
|
||||
reading.target_temperature,
|
||||
reading.device_setpoint,
|
||||
reading.outdoor_temperature,
|
||||
reading.power as i64,
|
||||
reading.mode,
|
||||
reading.fan_speed as i64,
|
||||
reading.demand as i64,
|
||||
reading.control_source,
|
||||
reading.active_preset,
|
||||
cutoff.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<ZoneReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(zone_id) = zone_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_zone_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<ZoneReading> {
|
||||
let timestamp: String = row.get(3)?;
|
||||
Ok(ZoneReading {
|
||||
id: row.get(0)?,
|
||||
zone_id: row.get(1)?,
|
||||
device_id: row.get(2)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
gree_temperature: row.get(4)?,
|
||||
external_temperature: row.get(5)?,
|
||||
control_temperature: row.get(6)?,
|
||||
target_temperature: row.get(7)?,
|
||||
device_setpoint: row.get(8)?,
|
||||
outdoor_temperature: row.get(9)?,
|
||||
power: row.get::<_, i64>(10)? != 0,
|
||||
mode: row.get(11)?,
|
||||
fan_speed: row.get::<_, i64>(12)?.clamp(0, 255) as u8,
|
||||
demand: row.get::<_, i64>(13)? != 0,
|
||||
control_source: row.get(14)?,
|
||||
active_preset: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
+18
-3782
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
||||
let devices = state.db.list_devices()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let mut automations = state.db.list_automations()?;
|
||||
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
|
||||
// This avoids database row order deciding the physical outcome (M2).
|
||||
automations.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id)));
|
||||
let mut claimed_devices = std::collections::HashSet::<String>::new();
|
||||
|
||||
for mut item in automations {
|
||||
if !item.enabled || !automation_ready(&item) { continue; }
|
||||
let should_fire = match item.trigger_kind.as_str() {
|
||||
"temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
||||
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
||||
"time" => time_automation_due(&item, Local::now()),
|
||||
_ => false,
|
||||
};
|
||||
if !should_fire { continue; }
|
||||
if item.action_group_id.is_none()
|
||||
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
|
||||
&& item.action.power != Some(true)
|
||||
{
|
||||
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
|
||||
"automation_id": item.id, "device_id": item.action_device_id
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
|
||||
state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({
|
||||
"automation_id": item.id, "device_id": item.action_device_id
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
|
||||
state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({
|
||||
"automation_id": item.id, "device_id": item.action_device_id
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
|
||||
// Group power-off is authoritative for normal controller-owned zones. A manual
|
||||
// takeover is filtered above and therefore remains higher priority than the group.
|
||||
state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({
|
||||
"automation_id": item.id, "device_id": item.action_device_id
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_devices: Vec<String> = if let Some(group_id) = item.action_group_id.as_deref() {
|
||||
groups.iter().find(|group| group.id == group_id)
|
||||
.map(|group| group.zone_ids.iter()
|
||||
.filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone()))
|
||||
.collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
vec![item.action_device_id.clone()]
|
||||
};
|
||||
if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) {
|
||||
state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({
|
||||
"automation_id": item.id,
|
||||
"group_id": item.action_group_id,
|
||||
"device_id": item.action_device_id,
|
||||
"target_devices": target_devices,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
let result: Result<bool, AppError> = if let Some(group_id) = item.action_group_id.as_deref() {
|
||||
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
||||
control_group(state, group_id, GroupControlPatch {
|
||||
power: item.action.power,
|
||||
mode: group_mode,
|
||||
preset: item.action_preset.clone(),
|
||||
}, "automation.group").await.map(|_| true)
|
||||
} else {
|
||||
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
|
||||
Ok(Some(_)) => Ok(true),
|
||||
Ok(None) => {
|
||||
state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({
|
||||
"automation_id": item.id, "device_id": item.action_device_id
|
||||
}));
|
||||
Ok(false)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(true) => {
|
||||
for device_id in target_devices { claimed_devices.insert(device_id); }
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
|
||||
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
||||
}));
|
||||
}
|
||||
Ok(false) => {
|
||||
// Ownership suppression is not an execution. Do not consume cooldown (M3),
|
||||
// so a still-valid trigger may run as soon as the higher-priority owner leaves.
|
||||
}
|
||||
Err(err) => {
|
||||
// A failed action is still an execution attempt. Apply the configured cooldown
|
||||
// so an offline/disabled target cannot be hammered on every automation cycle.
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool {
|
||||
zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled)
|
||||
}
|
||||
|
||||
fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool {
|
||||
zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override)
|
||||
}
|
||||
|
||||
fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool {
|
||||
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
|
||||
}
|
||||
|
||||
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
|
||||
let zone_ids: std::collections::HashSet<&str> = zones.iter()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id.as_str())
|
||||
.collect();
|
||||
if zone_ids.is_empty() { return false; }
|
||||
groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str())))
|
||||
}
|
||||
|
||||
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
||||
let id = device_id?;
|
||||
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
|
||||
devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature
|
||||
}
|
||||
|
||||
fn automation_ready(item: &Automation) -> bool {
|
||||
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
||||
}
|
||||
|
||||
fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
|
||||
let Some(expected) = item.at_time.as_deref() else { return false; };
|
||||
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
||||
if now.hour() != value.hour() || now.minute() != value.minute() { return false; }
|
||||
if let Some(last) = item.last_fired_at {
|
||||
let local_last = last.with_timezone(&Local);
|
||||
if local_last.date_naive() == now.date_naive()
|
||||
&& local_last.hour() == now.hour()
|
||||
&& local_last.minute() == now.minute()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
send_command_locked(state, device_id, command).await
|
||||
}
|
||||
|
||||
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
send_command_locked_inner(state, device_id, command, true, true).await
|
||||
}
|
||||
|
||||
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
send_command_locked_inner(state, device_id, command, false, true).await
|
||||
}
|
||||
|
||||
async fn send_command_locked_inner(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
dedupe_against_cache: bool,
|
||||
track_controller_command: bool,
|
||||
) -> 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())); }
|
||||
|
||||
// Routine control avoids redundant frames. Explicit safety transitions (global/group OFF,
|
||||
// detach) may bypass cache de-duplication so stale state cannot leave a unit powered.
|
||||
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command };
|
||||
if command.is_empty() { return Ok(device); }
|
||||
let controller_command_baseline = device.clone();
|
||||
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
||||
let response_started = Instant::now();
|
||||
|
||||
let mut applied_command = command.clone();
|
||||
let mut confirmed_state = false;
|
||||
let mut confirmed_requested_state = true;
|
||||
if device.simulated {
|
||||
applied_command.apply(&mut device);
|
||||
confirmed_state = true;
|
||||
device.online = true;
|
||||
device.response_time_ms = Some(0);
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
state.db.save_device(&device)?;
|
||||
} else {
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version}));
|
||||
}
|
||||
Err(err) => {
|
||||
register_device_failure(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
match state.gree.command(&device, &command, suppress_beep).await {
|
||||
Ok(result) => applied_command = result,
|
||||
Err(first_err) => {
|
||||
// A lost command ACK does not mean the command was lost. Read the device
|
||||
// first and avoid sending the same frame (and another beep) when the requested
|
||||
// state is already present. Only rebind when the verification read also fails.
|
||||
let mut observed = device.clone();
|
||||
let retry_result = match state.gree.poll(&mut observed).await {
|
||||
Ok(()) if command.changed_from(&observed).is_empty() => {
|
||||
device = observed;
|
||||
confirmed_state = true;
|
||||
tracing::debug!(device=%device.id, "GREE command ACK was uncertain, but status confirms the requested state");
|
||||
Ok(command.clone())
|
||||
}
|
||||
Ok(()) => {
|
||||
device = observed;
|
||||
let remaining = command.changed_from(&device);
|
||||
if remaining.is_empty() { Ok(command.clone()) }
|
||||
else { state.gree.command(&device, &remaining, suppress_beep).await }
|
||||
}
|
||||
Err(_) => {
|
||||
match state.gree.bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
state.db.save_device(&device)?;
|
||||
state.gree.command(&device, &command, suppress_beep).await
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
}
|
||||
}
|
||||
};
|
||||
match retry_result {
|
||||
Ok(result) => applied_command = result,
|
||||
Err(err) => {
|
||||
register_device_failure(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); }
|
||||
if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); }
|
||||
|
||||
// A command ACK confirms transport/acceptance, but several GREE firmwares keep
|
||||
// returning the pre-command status for a short settling window. Publishing that first
|
||||
// stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times
|
||||
// with bounded backoff and only publish a differing state after the settling window.
|
||||
if !confirmed_state {
|
||||
let verification_delays_ms = [0_u64, 150, 350, 650];
|
||||
let mut last_verification_error: Option<String> = None;
|
||||
for delay_ms in verification_delays_ms {
|
||||
if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; }
|
||||
let mut observed = device.clone();
|
||||
match state.gree.poll(&mut observed).await {
|
||||
Ok(()) => {
|
||||
let requested_matches = applied_command.changed_from(&observed).is_empty();
|
||||
device = observed;
|
||||
confirmed_state = true;
|
||||
confirmed_requested_state = requested_matches;
|
||||
last_verification_error = None;
|
||||
if requested_matches { break; }
|
||||
}
|
||||
Err(err) => {
|
||||
last_verification_error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if confirmed_state && !confirmed_requested_state {
|
||||
tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window");
|
||||
} else if !confirmed_state {
|
||||
let error = last_verification_error.unwrap_or_else(|| "status verification failed".into());
|
||||
record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}"));
|
||||
state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({
|
||||
"device_id": device.id, "error": error
|
||||
}));
|
||||
}
|
||||
}
|
||||
if confirmed_state {
|
||||
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
}
|
||||
state.db.save_device(&device)?;
|
||||
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
|
||||
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
||||
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
||||
}
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
return Err(AppError::Device("device did not confirm the requested forced state change".into()));
|
||||
}
|
||||
}
|
||||
|
||||
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
||||
// Keep a bounded settling history even after the requested state has already been
|
||||
// observed once. Several GREE modules can briefly publish an older snapshot again
|
||||
// and then return to the controller-requested state. Without this guard that normal
|
||||
// firmware bounce can be misclassified as a physical/pilot takeover.
|
||||
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
||||
}
|
||||
|
||||
record_device_transition_timestamps(state, &controller_command_baseline, &device)?;
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"command": applied_command,
|
||||
"confirmed": confirmed_state,
|
||||
}));
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
||||
if before.power == after.power && before.mode == after.mode { return Ok(()); }
|
||||
let now = Utc::now();
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
||||
if before.power != after.power { zone.last_power_change_at = Some(now); }
|
||||
if before.mode != after.mode { zone.last_mode_change_at = Some(now); }
|
||||
// Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field
|
||||
// as its optimistic snapshot guard. The cycle mirrors these timestamps into its own
|
||||
// computed Zone after a successful automatic command.
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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 zones = state.db.list_zones()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let house_preset = zones.first().and_then(|first| {
|
||||
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
|
||||
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
|
||||
.then(|| first_preset.to_string())
|
||||
});
|
||||
let house_power = settings.house_power_enabled;
|
||||
let now = Local::now();
|
||||
let night_active = night_mode_active(&settings.night_mode, now.time());
|
||||
let mut zones_out = Vec::new();
|
||||
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
|
||||
|
||||
for mut zone in zones {
|
||||
let device = devices.iter().find(|item| item.id == zone.device_id);
|
||||
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
||||
let blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group);
|
||||
let configured_effective_mode = configured_effective_mode_owned.as_str();
|
||||
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
|
||||
let effective_mode = if zone.device_manual_override {
|
||||
manual_device_mode.unwrap_or(configured_effective_mode)
|
||||
} else if zone.local_thermostat_power == Some(false) || blocked_by_group {
|
||||
"off"
|
||||
} else {
|
||||
configured_effective_mode
|
||||
};
|
||||
|
||||
// Keep the thermostat target readable even while the zone/group/house control is off.
|
||||
// Home Assistant climate entities otherwise expose target_temperature as unknown.
|
||||
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
|
||||
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
|
||||
let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode);
|
||||
let active = if effective_mode == "off" { None } else { active_for_target };
|
||||
let next_events = if effective_mode == "off" {
|
||||
Vec::new()
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
let effective_enabled = zone.enabled
|
||||
&& zone.local_thermostat_power != Some(false)
|
||||
&& (!blocked_by_group || zone.device_manual_override);
|
||||
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,
|
||||
effective_enabled,
|
||||
mode: effective_mode.to_string(),
|
||||
configured_mode: zone.mode.clone(),
|
||||
inherit_house_mode: zone.inherit_house_mode,
|
||||
preset: resolved_preset,
|
||||
preset_override: zone.manual_preset.clone(),
|
||||
current_temperature: zone.current_temperature,
|
||||
target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
|
||||
// A remote/manual takeover may leave a physical standby target persisted in the
|
||||
// zone runtime snapshot. Never publish that value as the thermostat target.
|
||||
Some(resolved_target)
|
||||
} else {
|
||||
zone.effective_setpoint.or(Some(resolved_target))
|
||||
},
|
||||
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
|
||||
desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && !blocked_by_group,
|
||||
desired_mode: effective_mode.to_string(),
|
||||
actual_power: device.map(|item| item.power),
|
||||
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }),
|
||||
actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
|
||||
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
manual_override_until: zone.manual_override_until,
|
||||
local_thermostat_power: zone.local_thermostat_power,
|
||||
local_thermostat_resume_at: zone.local_thermostat_resume_at,
|
||||
device_manual_override: zone.device_manual_override,
|
||||
device_manual_override_until: zone.device_manual_override_until,
|
||||
control_owner: zone.control_owner.clone(),
|
||||
control_command_source: zone.control_source.clone(),
|
||||
control_since: zone.control_since,
|
||||
resume_at: zone.control_resume_at,
|
||||
control_reason: zone.control_reason.clone(),
|
||||
blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if blocked_by_group { Some("group_off".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
|
||||
lockout_until: zone.lockout_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_group_name = item.action_group_id.as_deref()
|
||||
.and_then(|id| groups.iter().find(|group| group.id == id))
|
||||
.map(|group| group.name.clone());
|
||||
let action_name = action_group_name.clone().unwrap_or_else(|| {
|
||||
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_group_id: item.action_group_id,
|
||||
action_group_name,
|
||||
action_preset: item.action_preset,
|
||||
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,
|
||||
house_preset,
|
||||
house_power,
|
||||
outdoor_temperature: *state.outdoor_temperature.read().await,
|
||||
control_strategy: settings.control_strategy,
|
||||
night_mode_active: night_active,
|
||||
night_mode_start: settings.night_mode.start_time,
|
||||
night_mode_end: settings.night_mode.end_time,
|
||||
night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5),
|
||||
next_events: house_events,
|
||||
zones: zones_out,
|
||||
rules,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> {
|
||||
if !settings.enabled || limit == 0 { return Vec::new(); }
|
||||
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); };
|
||||
let mut events = Vec::new();
|
||||
let base = minute_floor(now);
|
||||
for minute in 1..=(48 * 60) {
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let time = candidate.time();
|
||||
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
|
||||
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
|
||||
("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet))
|
||||
} else if time.hour() == end.hour() && time.minute() == end.minute() {
|
||||
("night_mode_end", "Night mode ends".to_string())
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
events.push(ControlPlanEvent {
|
||||
at: candidate.with_timezone(&Utc),
|
||||
kind: kind.into(),
|
||||
label,
|
||||
preset: None,
|
||||
target_temperature: None,
|
||||
});
|
||||
if events.len() >= limit { break; }
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
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()?;
|
||||
let base = minute_floor(now.clone());
|
||||
if time_automation_due(item, now) {
|
||||
return Some(ControlPlanEvent {
|
||||
at: base.with_timezone(&Utc),
|
||||
kind: "automation".into(),
|
||||
label: format!("{} -> {}", item.name, action_name),
|
||||
preset: None,
|
||||
target_temperature: item.action.target_temperature,
|
||||
});
|
||||
}
|
||||
// A local day can last 25 hours at the end of daylight saving time.
|
||||
for minute in 1..=(26 * 60) {
|
||||
let candidate = base + 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());
|
||||
let base = minute_floor(now);
|
||||
for minute in 1..=(8 * 24 * 60) {
|
||||
let candidate = base + 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
fn next_time_automation_utc(item: &Automation, now: DateTime<Local>) -> Option<DateTime<Utc>> {
|
||||
if !item.enabled || item.trigger_kind != "time" { return None; }
|
||||
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
||||
let minute_floor = now.with_second(0)?.with_nanosecond(0)?;
|
||||
for offset in 0..=(24 * 60) {
|
||||
let candidate = minute_floor + chrono::Duration::minutes(offset);
|
||||
if candidate <= now { continue; }
|
||||
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
|
||||
return Some(candidate.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
|
||||
let now = Utc::now();
|
||||
let local_now = now.with_timezone(&Local);
|
||||
let zones = state.db.list_zones()?;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let automations = state.db.list_automations()?;
|
||||
let mut deadlines: Vec<DateTime<Utc>> = Vec::new();
|
||||
|
||||
for zone in &zones {
|
||||
if local_thermostat_handback_is_active(zone) {
|
||||
if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); }
|
||||
}
|
||||
if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); }
|
||||
if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); }
|
||||
}
|
||||
for automation in &automations {
|
||||
if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); }
|
||||
}
|
||||
|
||||
Ok(deadlines.into_iter()
|
||||
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
|
||||
.min())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group mode must be house, cool or heat".into()));
|
||||
}
|
||||
}
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut group = state.db.get_group(group_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let climate_change = patch.mode.is_some() || patch.preset.is_some();
|
||||
if let Some(power) = patch.power {
|
||||
group.power_enabled = power;
|
||||
}
|
||||
group.updated_at = Utc::now();
|
||||
state.db.save_group(&group)?;
|
||||
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
||||
|
||||
// Explicit group ON is a conscious request to run this group. Resume the global
|
||||
// master without changing the gates of any other groups. This makes group ON work
|
||||
// even after a previous whole-house OFF while preserving multi-group OFF priority.
|
||||
if patch.power == Some(true) {
|
||||
let mut settings = state.settings.write().await;
|
||||
if !settings.house_power_enabled {
|
||||
settings.house_power_enabled = true;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
state.broadcast("house.power_changed", json!({"house_power_enabled": true}));
|
||||
state.log("info", "house.power_resumed_by_group", &format!("Whole-house master resumed by group {}", group.name), json!({
|
||||
"group_id": group.id, "source": source
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let mut zones = Vec::new();
|
||||
for zone_id in &group.zone_ids {
|
||||
let _zone_guard = state.lock_zone_operation(zone_id).await;
|
||||
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
|
||||
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
||||
let temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
|
||||
if temporary_owns_zone {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
|
||||
if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); }
|
||||
}
|
||||
if climate_change {
|
||||
state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({
|
||||
"zone_id": zone.id, "group_id": group.id, "source": source
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
match mode {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = mode.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
if preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(preset.to_string());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
zones.push(zone);
|
||||
}
|
||||
|
||||
let runtime = state.settings.read().await.clone();
|
||||
let master_power_enabled = runtime.house_power_enabled;
|
||||
let should_command_power = patch.power.is_some();
|
||||
let desired_power = group.power_enabled;
|
||||
// A zone may intentionally belong to more than one group. Power-off is authoritative:
|
||||
// turning one group on must never briefly wake a member that is still blocked by another group.
|
||||
let mut failed = Vec::new();
|
||||
if should_command_power && !desired_power {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for zone in &zones {
|
||||
if !seen.insert(zone.device_id.clone()) { continue; }
|
||||
// Group OFF is an immediate safety transition. Group ON never emits a bare
|
||||
// power=true frame; the thermostat arbiter starts the unit with mode/target.
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
|
||||
if !device.enabled { continue; }
|
||||
match send_group_power_if_current(state, &group.id, &zone.id, &device.id, false).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
state.log("error", "group.power_error", &err.to_string(), json!({
|
||||
"group_id": group.id, "device_id": device.id, "device_name": device.name,
|
||||
"power": false, "source": source,
|
||||
}));
|
||||
failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if desired_power && (should_command_power || climate_change) {
|
||||
state.wake_zone_control();
|
||||
}
|
||||
state.log("info", source, &format!("Updated group {}", group.name), json!({
|
||||
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
|
||||
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
|
||||
}));
|
||||
Ok(json!({
|
||||
"group": group,
|
||||
"zones": zones,
|
||||
"devices": state.db.list_devices()?,
|
||||
"failed": failed,
|
||||
"master_power_enabled": master_power_enabled,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) {
|
||||
let device = match state.db.get_device(&zone.device_id) {
|
||||
Ok(Some(device)) => device,
|
||||
Ok(None) => return,
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reading = ZoneReading {
|
||||
id: 0,
|
||||
zone_id: zone.id.clone(),
|
||||
device_id: zone.device_id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
gree_temperature: zone.device_temperature.or(device.current_temperature),
|
||||
external_temperature: zone.external_temperature,
|
||||
control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature),
|
||||
target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)),
|
||||
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
|
||||
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
|
||||
power: device.power,
|
||||
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() },
|
||||
fan_speed: device.fan_speed,
|
||||
demand: zone.demand,
|
||||
control_source: zone.control_temperature_source.clone(),
|
||||
active_preset: zone.active_preset.clone(),
|
||||
};
|
||||
let interval = poll_interval_seconds.max(15) as i64;
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_ha_history(
|
||||
state: &AppState,
|
||||
entity_id: &str,
|
||||
zone_id: Option<&str>,
|
||||
kind: &str,
|
||||
temperature: f64,
|
||||
poll_interval_seconds: u64,
|
||||
) {
|
||||
let reading = HaReading {
|
||||
id: 0,
|
||||
entity_id: entity_id.to_string(),
|
||||
zone_id: zone_id.map(str::to_string),
|
||||
kind: kind.to_string(),
|
||||
timestamp: Utc::now(),
|
||||
temperature,
|
||||
};
|
||||
let interval = poll_interval_seconds.max(15) as i64;
|
||||
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 gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
|
||||
let mut values: Vec<f64> = devices.iter()
|
||||
.filter(|device| device.enabled && device.online && device.communication_failures == 0)
|
||||
.filter_map(|device| device.outdoor_temperature)
|
||||
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
|
||||
.collect();
|
||||
if values.is_empty() { return None; }
|
||||
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let middle = values.len() / 2;
|
||||
let value = if values.len() % 2 == 0 {
|
||||
(values[middle - 1] + values[middle]) / 2.0
|
||||
} else {
|
||||
values[middle]
|
||||
};
|
||||
Some((value * 10.0).round() / 10.0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
|
||||
|
||||
/// Apply local quick-thermostat power ownership and keep the automatic hand-back
|
||||
/// deadline in one backend-owned place. Every fresh OFF action receives a fresh
|
||||
/// deadline; ON cancels any pending hand-back.
|
||||
pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime<Utc>) -> bool {
|
||||
let previous_restore = zone.local_thermostat_restore_zone_enabled;
|
||||
// Ordinary Quick Thermostat has its own restore state. A temporary session never uses
|
||||
// this field, so its lifecycle cannot be erased by the 15-minute local hand-back.
|
||||
if power && zone.local_thermostat_power != Some(true) && zone.local_thermostat_restore_zone_enabled.is_none() {
|
||||
zone.local_thermostat_restore_zone_enabled = Some(zone.enabled);
|
||||
}
|
||||
let resume_at = if power {
|
||||
None
|
||||
} else {
|
||||
Some(now + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES))
|
||||
};
|
||||
let changed = zone.local_thermostat_power != Some(power)
|
||||
|| zone.local_thermostat_resume_at != resume_at
|
||||
|| zone.local_thermostat_restore_zone_enabled != previous_restore;
|
||||
zone.local_thermostat_power = Some(power);
|
||||
zone.local_thermostat_resume_at = resume_at;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Re-arm a local-OFF hand-back after a temporary direct/manual device takeover.
|
||||
/// The countdown must start from the moment that manual control ends, not from the
|
||||
/// older OFF action that happened before the takeover.
|
||||
fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime<Utc>) -> bool {
|
||||
if zone.local_thermostat_power != Some(false) { return false; }
|
||||
set_local_thermostat_power(zone, false, now)
|
||||
}
|
||||
|
||||
fn local_thermostat_handback_is_active(zone: &Zone) -> bool {
|
||||
zone.local_thermostat_power == Some(false) && !zone.device_manual_override
|
||||
}
|
||||
|
||||
/// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is
|
||||
/// deliberately untouched; the two ownership mechanisms have independent cleanup paths.
|
||||
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
|
||||
let temporary_active = zone.temporary_quick_thermostat.as_ref()
|
||||
.map(|session| session.activated_at.is_some())
|
||||
.unwrap_or(false);
|
||||
if temporary_active {
|
||||
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return false; };
|
||||
let changed = session.restore_local_thermostat_power.is_some()
|
||||
|| session.restore_local_thermostat_resume_at.is_some()
|
||||
|| session.restore_local_thermostat_zone_enabled.is_some()
|
||||
|| session.restore_manual_preset.is_some()
|
||||
|| session.restore_manual_setpoint.is_some()
|
||||
|| session.restore_manual_override_until.is_some();
|
||||
session.restore_local_thermostat_power = None;
|
||||
session.restore_local_thermostat_resume_at = None;
|
||||
session.restore_local_thermostat_zone_enabled = None;
|
||||
session.restore_manual_preset = None;
|
||||
session.restore_manual_setpoint = None;
|
||||
session.restore_manual_override_until = None;
|
||||
return changed;
|
||||
}
|
||||
|
||||
let restore_zone_enabled = zone.local_thermostat_restore_zone_enabled;
|
||||
let changed = zone.local_thermostat_power.is_some()
|
||||
|| zone.local_thermostat_resume_at.is_some()
|
||||
|| zone.local_thermostat_restore_zone_enabled.is_some()
|
||||
|| zone.manual_preset.is_some()
|
||||
|| zone.manual_setpoint.is_some()
|
||||
|| zone.manual_override_until.is_some();
|
||||
zone.local_thermostat_power = None;
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
if let Some(enabled) = restore_zone_enabled {
|
||||
zone.enabled = enabled;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: bool) {
|
||||
let now = Utc::now();
|
||||
let (owner, source, resume_at, reason) = if !house_power_enabled {
|
||||
("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string())
|
||||
} else if zone.device_manual_override {
|
||||
let source = match zone.control_source.as_str() {
|
||||
"home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(),
|
||||
_ => "external".into(),
|
||||
};
|
||||
("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string())
|
||||
} else if zone.local_thermostat_power.is_some() {
|
||||
let source = match zone.control_source.as_str() {
|
||||
"home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(),
|
||||
_ => "local_thermostat".into(),
|
||||
};
|
||||
let resume_at = zone.temporary_quick_thermostat.as_ref()
|
||||
.and_then(temporary_quick_thermostat_next_deadline)
|
||||
.or(zone.local_thermostat_resume_at.clone());
|
||||
let reason = if zone.local_thermostat_power == Some(false) {
|
||||
"Local thermostat is explicitly off".into()
|
||||
} else if zone.temporary_quick_thermostat.is_some() {
|
||||
"Temporary Quick Thermostat owns the zone".into()
|
||||
} else {
|
||||
"Local thermostat owns the zone".into()
|
||||
};
|
||||
("local_thermostat", source, resume_at, reason)
|
||||
} else if blocked_by_group {
|
||||
("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string())
|
||||
} else {
|
||||
("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
|
||||
};
|
||||
if zone.control_owner != owner || zone.control_source != source {
|
||||
zone.control_since = Some(now);
|
||||
} else if zone.control_since.is_none() {
|
||||
zone.control_since = Some(now);
|
||||
}
|
||||
zone.control_owner = owner.into();
|
||||
zone.control_source = source;
|
||||
zone.control_resume_at = resume_at;
|
||||
zone.control_reason = reason;
|
||||
}
|
||||
|
||||
fn normalized_direct_source(source: &str) -> &'static str {
|
||||
if source.contains("home_assistant") { "home_assistant_direct" }
|
||||
else if source == "device.manual_control" { "web_direct" }
|
||||
else { "external" }
|
||||
}
|
||||
|
||||
pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
|
||||
let now = Utc::now();
|
||||
let changed = zone.device_manual_override
|
||||
|| zone.device_manual_override_since.is_some()
|
||||
|| zone.device_manual_override_until.is_some()
|
||||
|| !zone.device_manual_override_fields.is_empty()
|
||||
|| zone.device_manual_override_baseline.is_some();
|
||||
zone.device_manual_override = false;
|
||||
zone.device_manual_override_since = None;
|
||||
zone.device_manual_override_until = None;
|
||||
zone.device_manual_override_fields.clear();
|
||||
zone.device_manual_override_baseline = None;
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
let pause = session.paused_at.take()
|
||||
.map(|paused_at| now.signed_duration_since(paused_at))
|
||||
.filter(|pause| *pause > chrono::Duration::zero());
|
||||
if session.activated_at.is_some() {
|
||||
if let Some(pause) = pause {
|
||||
if matches!(session.finish_kind.as_str(), "duration" | "until") {
|
||||
session.expires_at = session.expires_at.map(|at| at + pause);
|
||||
}
|
||||
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
|
||||
session.safety_expires_at = session.safety_expires_at.map(|at| at + pause);
|
||||
}
|
||||
}
|
||||
session.state = "active".into();
|
||||
} else {
|
||||
// A due session blocked by manual ownership has not started its work clock.
|
||||
// Preserve the requested remaining `until` window by excluding manual wait time.
|
||||
if session.finish_kind == "until" {
|
||||
if let Some(pause) = pause {
|
||||
session.expires_at = session.expires_at.map(|at| at + pause);
|
||||
}
|
||||
}
|
||||
session.state = "scheduled".into();
|
||||
}
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
if zone.control_owner == "direct_manual" {
|
||||
zone.control_owner = "automation".into();
|
||||
zone.control_source = "automation".into();
|
||||
zone.control_since = Some(now);
|
||||
zone.control_resume_at = None;
|
||||
zone.control_reason = "Manual takeover cleared; automation may resume".into();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool {
|
||||
let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; };
|
||||
if zone.device_manual_override_fields.is_empty() { return false; }
|
||||
// If the unit was OFF before takeover, returning it to OFF is operationally the same
|
||||
// controller state even if the remote retained a different mode/target internally.
|
||||
// Those dormant values will be set explicitly if automation later powers the unit.
|
||||
if !baseline.power { return !device.power; }
|
||||
zone.device_manual_override_fields.iter().all(|field| match field.as_str() {
|
||||
"power" => device.power == baseline.power,
|
||||
"mode" => device.mode == baseline.mode,
|
||||
"target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(),
|
||||
"fan_speed" => device.fan_speed == baseline.fan_speed,
|
||||
"quiet" => device.quiet == baseline.quiet,
|
||||
"sleep" => device.sleep == baseline.sleep,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result<bool, AppError> {
|
||||
if !reset_device_manual_override(zone) { return Ok(false); }
|
||||
let now = Utc::now();
|
||||
let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone());
|
||||
zone.updated_at = now;
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
let (kind, message) = if restored {
|
||||
("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name))
|
||||
} else {
|
||||
("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name))
|
||||
};
|
||||
state.log("info", kind, &message, json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "source": source,
|
||||
"local_thermostat_resume_rearmed": local_resume_rearmed,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
}));
|
||||
state.wake_zone_control();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<String>, source: &str, baseline: &Device) -> Result<(), AppError> {
|
||||
if fields.is_empty() { return Ok(()); }
|
||||
let now = Utc::now();
|
||||
if !zone.device_manual_override {
|
||||
zone.device_manual_override_since = Some(now);
|
||||
zone.device_manual_override_baseline = Some(baseline.into());
|
||||
zone.control_since = Some(now);
|
||||
}
|
||||
zone.device_manual_override = true;
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
// A future scheduled session has no ownership yet. Do not mark it paused until its
|
||||
// requested start actually becomes due while manual control is still present.
|
||||
if session.activated_at.is_some() || session.started_at <= now {
|
||||
if session.paused_at.is_none() { session.paused_at = Some(now); }
|
||||
session.state = "paused_manual".into();
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
}
|
||||
zone.control_owner = "direct_manual".into();
|
||||
zone.control_source = normalized_direct_source(source).into();
|
||||
zone.control_reason = "Direct/manual device control has priority".into();
|
||||
zone.device_manual_override_until = if zone.enabled {
|
||||
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
zone.control_resume_at = zone.device_manual_override_until;
|
||||
for field in fields {
|
||||
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) {
|
||||
zone.device_manual_override_fields.push(field);
|
||||
}
|
||||
}
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.updated_at = now;
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_id": zone.device_id,
|
||||
"fields": zone.device_manual_override_fields,
|
||||
"source": source,
|
||||
"override_until": zone.device_manual_override_until,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
||||
if before.id != after.id { return Ok(()); }
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
||||
let raw_fields = externally_changed_control_fields(before, after, &zone);
|
||||
let controller_settling = if raw_fields.is_empty() {
|
||||
json!({ "active": false, "reason": "no_changed_control_fields" })
|
||||
} else {
|
||||
controller_settling_diagnostics(state, &after.id).await
|
||||
};
|
||||
let fields = suppress_expected_controller_changes(
|
||||
state,
|
||||
after,
|
||||
raw_fields.clone(),
|
||||
).await;
|
||||
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
|
||||
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
|
||||
continue;
|
||||
}
|
||||
if fields.is_empty() { continue; }
|
||||
// A disabled zone is outside controller ownership. When its manually operated unit is
|
||||
// switched off there is no takeover left to display or remember.
|
||||
if !zone.enabled && !after.power {
|
||||
persist_manual_override_clear(state, &mut zone, "gree_poll", false)?;
|
||||
continue;
|
||||
}
|
||||
state.log("info", "device.remote_control_detected", &format!("External/pilot control detected for {}", zone.name), json!({
|
||||
"zone_id": zone.id.clone(),
|
||||
"device_id": zone.device_id.clone(),
|
||||
"raw_fields": raw_fields,
|
||||
"detected_fields": fields.clone(),
|
||||
"before": device_control_snapshot(before),
|
||||
"after": device_control_snapshot(after),
|
||||
"controller_settling": controller_settling,
|
||||
"source": "gree_poll",
|
||||
"zone_state": {
|
||||
"enabled": zone.enabled,
|
||||
"control_owner": zone.control_owner.clone(),
|
||||
"control_source": zone.control_source.clone(),
|
||||
"demand": zone.demand,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(),
|
||||
},
|
||||
}));
|
||||
set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
|
||||
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
|
||||
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
|
||||
let zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
|
||||
let mut _zone_guards = Vec::new();
|
||||
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
|
||||
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
|
||||
// could observe our own just-sent command before the controller records manual ownership.
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
let before = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
|
||||
let fields = command_manual_control_fields(&effective_command);
|
||||
let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
|
||||
if !fields.is_empty() {
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
||||
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
|
||||
persist_manual_override_clear(state, &mut zone, source, true)?;
|
||||
continue;
|
||||
}
|
||||
if !zone.enabled && !updated.power {
|
||||
persist_manual_override_clear(state, &mut zone, source, false)?;
|
||||
continue;
|
||||
}
|
||||
set_device_manual_override(state, &mut zone, fields.clone(), source, &before)?;
|
||||
}
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result<Device, AppError> {
|
||||
// Global OFF is a one-shot authority transition. Clear takeover and send OFF while
|
||||
// polling for this unit is excluded; a later remote change happens after the lock and
|
||||
// is therefore preserved as a new manual takeover.
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
||||
if !reset_device_manual_override(&mut zone) { continue; }
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "source": source
|
||||
}));
|
||||
}
|
||||
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
|
||||
}
|
||||
|
||||
pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
|
||||
}
|
||||
|
||||
/// Technical device disable is a safety transition, not just a database flag. The unit is
|
||||
/// explicitly powered off while it is still commandable, then removed from controller polling.
|
||||
pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
if !device.enabled { return Ok(device); }
|
||||
device = send_command_locked_forced(
|
||||
state,
|
||||
device_id,
|
||||
DeviceCommand { power: Some(false), ..Default::default() },
|
||||
).await?;
|
||||
device.enabled = false;
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
state.wake_zone_control();
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result<usize, AppError> {
|
||||
let mut cleared = 0usize;
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if !reset_device_manual_override(&mut zone) { continue; }
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "source": source
|
||||
}));
|
||||
cleared += 1;
|
||||
}
|
||||
Ok(cleared)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
poll_one_locked(state, device_id).await
|
||||
}
|
||||
|
||||
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
let before = device.clone();
|
||||
poll_device(state, &mut device).await;
|
||||
if poll_completed_successfully(&device) {
|
||||
if state.initial_device_sync_complete.load(Ordering::Acquire) {
|
||||
record_device_transition_timestamps(state, &before, &device)?;
|
||||
}
|
||||
detect_external_device_control(state, &before, &device).await?;
|
||||
}
|
||||
state.db.save_device(&device)?;
|
||||
record_reading(state, &device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
||||
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
|
||||
.filter(|device| device.enabled)
|
||||
.map(|device| device.id)
|
||||
.collect();
|
||||
for device_id in device_ids {
|
||||
let _device_guard = state.lock_device_operation(&device_id).await;
|
||||
let _ = poll_one_locked(state, &device_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
if device.simulated {
|
||||
simulate_tick(device);
|
||||
return;
|
||||
}
|
||||
let previous_failures = device.communication_failures;
|
||||
let response_started = Instant::now();
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
}
|
||||
Err(err) => {
|
||||
record_poll_failure(device, &err.to_string());
|
||||
log_poll_health_transition(state, device, previous_failures).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(first_err) = state.gree.poll(device).await {
|
||||
// One lost UDP response is common on Wi-Fi and must not trigger a bind storm.
|
||||
// Rebind only after at least one consecutive failed poll; a successful retry clears
|
||||
// the counter in GreeClient::poll.
|
||||
if previous_failures == 0 {
|
||||
record_poll_failure(device, &first_err.to_string());
|
||||
} else {
|
||||
match state.gree.bind(device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
if let Err(err) = state.gree.poll(device).await {
|
||||
record_poll_failure(device, &err.to_string());
|
||||
}
|
||||
}
|
||||
Err(_) => record_poll_failure(device, &first_err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
if device.communication_failures == 0 && device.online {
|
||||
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
}
|
||||
log_poll_health_transition(state, device, previous_failures).await;
|
||||
}
|
||||
|
||||
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) {
|
||||
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
|
||||
let current_failures = u32::from(device.communication_failures);
|
||||
let previous_failures = u32::from(previous_failures);
|
||||
if current_failures >= threshold && previous_failures < threshold {
|
||||
state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({
|
||||
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
|
||||
}));
|
||||
} else if current_failures == 0 && previous_failures >= threshold {
|
||||
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id}));
|
||||
}
|
||||
}
|
||||
|
||||
fn simulate_tick(device: &mut Device) {
|
||||
let mut current = device.current_temperature.unwrap_or(25.0);
|
||||
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
|
||||
let ambient = 25.5 + minute_wave * 0.35;
|
||||
if device.power {
|
||||
match device.mode.as_str() {
|
||||
"cool" => {
|
||||
let floor = device.target_temperature - 0.2;
|
||||
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; }
|
||||
}
|
||||
"heat" => {
|
||||
let ceiling = device.target_temperature + 0.2;
|
||||
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; }
|
||||
}
|
||||
"dry" => current -= 0.04,
|
||||
_ => current += (ambient - current) * 0.02,
|
||||
}
|
||||
} else {
|
||||
current += (ambient - current) * 0.04;
|
||||
}
|
||||
device.current_temperature = Some((current * 10.0).round() / 10.0);
|
||||
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
|
||||
// Correct rounding for outdoor temperature without accumulating precision noise.
|
||||
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
|
||||
device.online = true;
|
||||
device.response_time_ms = Some(0);
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
let reading = Reading {
|
||||
id: 0,
|
||||
device_id: device.id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
indoor_temperature: device.current_temperature,
|
||||
outdoor_temperature: device.outdoor_temperature,
|
||||
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(())
|
||||
}
|
||||
|
||||
fn record_poll_failure(device: &mut Device, error: &str) {
|
||||
device.communication_failures = device.communication_failures.saturating_add(1);
|
||||
// A single dropped UDP response is not enough to declare an AC offline.
|
||||
if device.communication_failures >= 3 { device.online = false; }
|
||||
device.last_error = Some(error.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
|
||||
record_poll_failure(device, error);
|
||||
state.db.save_device(device)?;
|
||||
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"consecutive_failures": device.communication_failures,
|
||||
"offline": !device.online,
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
if let Some(value) = command.target_temperature {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
|
||||
}
|
||||
if let Some(value) = command.fan_speed {
|
||||
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }
|
||||
}
|
||||
if let Some(value) = &command.mode {
|
||||
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
|
||||
return Err(AppError::BadRequest("unsupported HVAC mode".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll_completed_successfully(device: &Device) -> bool {
|
||||
device.online && device.communication_failures == 0 && device.last_error.is_none()
|
||||
}
|
||||
|
||||
fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
|
||||
let mut fields = Vec::new();
|
||||
if command.power.is_some() { fields.push("power".to_string()); }
|
||||
if command.mode.is_some() { fields.push("mode".to_string()); }
|
||||
if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); }
|
||||
if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); }
|
||||
if command.quiet.is_some() { fields.push("quiet".to_string()); }
|
||||
if command.sleep.is_some() { fields.push("sleep".to_string()); }
|
||||
fields
|
||||
}
|
||||
|
||||
fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> DeviceCommand {
|
||||
DeviceCommand {
|
||||
power: command.power.map(|_| device.power),
|
||||
mode: command.mode.as_ref().map(|_| device.mode.clone()),
|
||||
target_temperature: command.target_temperature.map(|_| device.target_temperature),
|
||||
fan_speed: command.fan_speed.map(|_| device.fan_speed),
|
||||
quiet: command.quiet.map(|_| device.quiet),
|
||||
sleep: command.sleep.map(|_| device.sleep),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) {
|
||||
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
|
||||
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
|
||||
let mut pending = state.pending_controller_commands.lock().await;
|
||||
let expires_at = Instant::now() + ttl;
|
||||
let baseline = command_baseline_from_device(command, baseline_device);
|
||||
if let Some(existing) = pending.get_mut(device_id) {
|
||||
existing.commands.push(command.clone());
|
||||
existing.baselines.push(baseline);
|
||||
// The history only spans one settling window; cap it defensively so a noisy device
|
||||
// cannot grow this allocation without bound.
|
||||
if existing.commands.len() > 8 { existing.commands.remove(0); }
|
||||
if existing.baselines.len() > 8 { existing.baselines.remove(0); }
|
||||
existing.expires_at = expires_at;
|
||||
} else {
|
||||
pending.insert(device_id.to_string(), PendingControllerCommand {
|
||||
commands: vec![command.clone()],
|
||||
baselines: vec![baseline],
|
||||
expires_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
|
||||
match field {
|
||||
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
|
||||
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false),
|
||||
"target_temperature" => command.target_temperature
|
||||
.map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).round())
|
||||
.unwrap_or(false),
|
||||
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false),
|
||||
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false),
|
||||
"sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn device_control_snapshot(device: &Device) -> Value {
|
||||
json!({
|
||||
"power": device.power,
|
||||
"mode": device.mode.clone(),
|
||||
"target_temperature": device.target_temperature,
|
||||
"fan_speed": device.fan_speed,
|
||||
"quiet": device.quiet,
|
||||
"sleep": device.sleep,
|
||||
"turbo": device.turbo,
|
||||
"swing_vertical": device.swing_vertical,
|
||||
"swing_horizontal": device.swing_horizontal,
|
||||
"online": device.online,
|
||||
"communication_failures": device.communication_failures,
|
||||
"last_seen": device.last_seen.clone(),
|
||||
"updated_at": device.updated_at.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn controller_settling_diagnostics(state: &AppState, device_id: &str) -> Value {
|
||||
let pending = state.pending_controller_commands.lock().await;
|
||||
let Some(expected) = pending.get(device_id).cloned() else {
|
||||
return json!({ "active": false, "reason": "none" });
|
||||
};
|
||||
let now = Instant::now();
|
||||
if now > expected.expires_at {
|
||||
let expired_by_ms = now.saturating_duration_since(expected.expires_at).as_millis().min(u64::MAX as u128) as u64;
|
||||
return json!({
|
||||
"active": false,
|
||||
"reason": "expired",
|
||||
"expired_by_ms": expired_by_ms,
|
||||
"commands": expected.commands,
|
||||
"baselines": expected.baselines,
|
||||
});
|
||||
}
|
||||
let remaining_ms = expected.expires_at.saturating_duration_since(now).as_millis().min(u64::MAX as u128) as u64;
|
||||
json!({
|
||||
"active": true,
|
||||
"remaining_ms": remaining_ms,
|
||||
"commands": expected.commands,
|
||||
"baselines": expected.baselines,
|
||||
})
|
||||
}
|
||||
|
||||
async fn suppress_expected_controller_changes(
|
||||
state: &AppState,
|
||||
device: &Device,
|
||||
fields: Vec<String>,
|
||||
) -> Vec<String> {
|
||||
if fields.is_empty() { return fields; }
|
||||
let mut pending = state.pending_controller_commands.lock().await;
|
||||
let expired = pending.get(&device.id)
|
||||
.map(|expected| Instant::now() > expected.expires_at)
|
||||
.unwrap_or(false);
|
||||
if expired {
|
||||
pending.remove(&device.id);
|
||||
return fields;
|
||||
}
|
||||
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
|
||||
let filtered = fields.into_iter()
|
||||
.filter(|field| {
|
||||
let matches_recent_controller_state = expected.commands.iter()
|
||||
.chain(expected.baselines.iter())
|
||||
.any(|command| command_field_matches_device(command, field, device));
|
||||
!matches_recent_controller_state
|
||||
})
|
||||
.collect();
|
||||
// Do not clear the settling guard merely because one poll matched the requested state.
|
||||
// A later status packet can still briefly roll back to the pre-command snapshot. The
|
||||
// bounded TTL is what ends this ambiguity window.
|
||||
filtered
|
||||
}
|
||||
|
||||
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
|
||||
let mut fields = Vec::new();
|
||||
if before.power != after.power { fields.push("power".to_string()); }
|
||||
if before.mode != after.mode { fields.push("mode".to_string()); }
|
||||
if before.target_temperature.round() != after.target_temperature.round() {
|
||||
fields.push("target_temperature".to_string());
|
||||
}
|
||||
// Some GREE units accept the controller's standby Low fan hint and later report Auto
|
||||
// again without user interaction. Treat that one known normalization as firmware drift,
|
||||
// not as a remote-control takeover. Other fan changes remain meaningful manual input.
|
||||
let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
|
||||
if before.fan_speed != after.fan_speed && !standby_low_to_auto {
|
||||
fields.push("fan_speed".to_string());
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
fn reset_temporary_condition_observations_after_restart(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut changed = 0usize;
|
||||
for mut zone in state.db.list_zones()? {
|
||||
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { continue; };
|
||||
if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { continue; }
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
changed += 1;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn start(state: AppState) {
|
||||
// A continuous temperature hold cannot span controller downtime. Preserve the session
|
||||
// itself, but require fresh observations after every process restart (H11).
|
||||
if let Err(err) = reset_temporary_condition_observations_after_restart(&state) {
|
||||
tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart");
|
||||
}
|
||||
let poll_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
loop {
|
||||
match poll_all(&poll_state).await {
|
||||
Ok(()) => {
|
||||
if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) {
|
||||
tracing::info!("initial device state synchronized; thermostat control enabled");
|
||||
}
|
||||
}
|
||||
Err(err) => tracing::error!(error=?err, "device poll cycle failed"),
|
||||
}
|
||||
let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2);
|
||||
sleep(Duration::from_secs(seconds)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let control_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
loop {
|
||||
// A restart must never make decisions from the persisted, potentially stale
|
||||
// device snapshot. Wait for one full live poll before thermostat/schedule/automation
|
||||
// ownership can emit commands. Manual API/remote control remains available.
|
||||
if !control_state.initial_device_sync_complete.load(Ordering::Acquire) {
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = control_zones(&control_state).await {
|
||||
tracing::error!(error=?err, "zone cycle failed");
|
||||
}
|
||||
if let Err(err) = run_automations(&control_state).await {
|
||||
tracing::error!(error=?err, "automation cycle failed");
|
||||
}
|
||||
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
|
||||
let normal_delay = Duration::from_secs(seconds);
|
||||
let resume_delay = match next_zone_control_deadline_delay(&control_state) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(error=?err, "cannot calculate thermostat control deadline");
|
||||
None
|
||||
}
|
||||
};
|
||||
let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay);
|
||||
tokio::select! {
|
||||
_ = sleep(sleep_for) => {},
|
||||
_ = control_state.zone_control_wakeup.notified() => {},
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let maintenance_state = state;
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
loop {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
let event_retention_days = settings.event_log_retention_days.max(1) as i64;
|
||||
match maintenance_state.db.prune_events(event_retention_days) {
|
||||
Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot prune event log"),
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
// Overlaps are rejected by the API, but imported/legacy data may still contain one.
|
||||
// Prefer the most recently edited entry instead of depending on database/name order.
|
||||
.max_by_key(|item| item.updated_at)
|
||||
}
|
||||
|
||||
fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
|
||||
now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now)
|
||||
}
|
||||
|
||||
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> Option<DateTime<Utc>> {
|
||||
let current = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
||||
.max_by_key(|item| item.updated_at)
|
||||
.map(|item| item.id.as_str());
|
||||
let base = minute_floor(now);
|
||||
// Eight days cover a complete weekly schedule plus the next transition.
|
||||
for minute in 1..=(8 * 24 * 60) {
|
||||
let candidate = base + chrono::Duration::minutes(minute);
|
||||
let next = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
|
||||
.max_by_key(|item| item.updated_at)
|
||||
.map(|item| item.id.as_str());
|
||||
if next != current {
|
||||
return Some(candidate.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
// No schedule transition exists: keep a manual override until the user clears it.
|
||||
None
|
||||
}
|
||||
|
||||
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
|
||||
let time = now.time();
|
||||
let today = now.weekday().number_from_monday();
|
||||
if start == end {
|
||||
// Equal times mean a 24-hour block starting on each selected weekday.
|
||||
if time >= start {
|
||||
item.weekdays.contains(&today)
|
||||
} else {
|
||||
let previous = previous_weekday(now.weekday()).number_from_monday();
|
||||
item.weekdays.contains(&previous)
|
||||
}
|
||||
} else if start < end {
|
||||
item.weekdays.contains(&today) && time >= start && time < end
|
||||
} else if time >= start {
|
||||
item.weekdays.contains(&today)
|
||||
} else if time < end {
|
||||
let previous = previous_weekday(now.weekday()).number_from_monday();
|
||||
item.weekdays.contains(&previous)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_week_mask(item: &Schedule) -> Option<Vec<bool>> {
|
||||
let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?;
|
||||
let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?;
|
||||
let start_minute = (start.hour() * 60 + start.minute()) as usize;
|
||||
let end_minute = (end.hour() * 60 + end.minute()) as usize;
|
||||
let mut mask = vec![false; 7 * 24 * 60];
|
||||
for weekday in &item.weekdays {
|
||||
if !(1..=7).contains(weekday) { return None; }
|
||||
let day = (*weekday as usize) - 1;
|
||||
let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| {
|
||||
let base = (day % 7) * 24 * 60;
|
||||
for minute in from..to { mask[base + minute] = true; }
|
||||
};
|
||||
if start_minute == end_minute {
|
||||
mark(&mut mask, day, start_minute, 24 * 60);
|
||||
mark(&mut mask, day + 1, 0, end_minute);
|
||||
} else if start_minute < end_minute {
|
||||
mark(&mut mask, day, start_minute, end_minute);
|
||||
} else {
|
||||
mark(&mut mask, day, start_minute, 24 * 60);
|
||||
mark(&mut mask, day + 1, 0, end_minute);
|
||||
}
|
||||
}
|
||||
Some(mask)
|
||||
}
|
||||
|
||||
pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool {
|
||||
if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; }
|
||||
let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; };
|
||||
left.iter().zip(right.iter()).any(|(a, b)| *a && *b)
|
||||
}
|
||||
|
||||
fn previous_weekday(day: Weekday) -> Weekday {
|
||||
match day {
|
||||
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
|
||||
Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri,
|
||||
Weekday::Sun => Weekday::Sat,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
|
||||
if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; }
|
||||
match (mode, preset) {
|
||||
("heat", "sleep") => zone.heat_sleep_setpoint,
|
||||
("heat", "away") => zone.heat_away_setpoint,
|
||||
("heat", _) => zone.heat_comfort_setpoint,
|
||||
(_, "sleep") => zone.cool_sleep_setpoint,
|
||||
(_, "away") => zone.cool_away_setpoint,
|
||||
(_, _) => zone.cool_comfort_setpoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) {
|
||||
let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() {
|
||||
if manual == "custom" {
|
||||
("custom".into(), zone.setpoint)
|
||||
} else {
|
||||
(manual.to_string(), profile_setpoint(zone, manual, mode))
|
||||
}
|
||||
} else if let Some(item) = schedule {
|
||||
if item.preset == "custom" {
|
||||
("custom".into(), item.setpoint)
|
||||
} else {
|
||||
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
|
||||
}
|
||||
} else {
|
||||
("comfort".into(), profile_setpoint(zone, "comfort", mode))
|
||||
};
|
||||
// Quick +/- temperature adjustments are independent from the selected preset.
|
||||
// The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target.
|
||||
(preset, zone.manual_setpoint.unwrap_or(base_target))
|
||||
}
|
||||
|
||||
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
|
||||
let configured_mode = effective_zone_mode(zone, house_mode);
|
||||
zone.effective_mode = configured_mode.clone();
|
||||
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() };
|
||||
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
if !zone.device_manual_override {
|
||||
zone.effective_setpoint = Some(target);
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
|
||||
if temporary_quick_thermostat_is_active(zone, Utc::now()) {
|
||||
if let Some(mode) = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.active_mode.as_deref()) {
|
||||
if matches!(mode, "cool" | "heat") { return mode.to_string(); }
|
||||
}
|
||||
}
|
||||
let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
|
||||
if zone.local_thermostat_power == Some(true) && configured == "off" {
|
||||
zone.mode.clone()
|
||||
} else {
|
||||
configured.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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) {
|
||||
(Some(value), _) => (Some(value), "external".into(), false),
|
||||
(None, Some(value)) => (Some(value), "device_fallback".into(), false),
|
||||
(None, None) => (None, "unavailable".into(), false),
|
||||
},
|
||||
"combined" => match (device_temperature, external_temperature) {
|
||||
(Some(device), Some(external)) => {
|
||||
if (device - external).abs() > zone.max_sensor_difference.max(0.1) {
|
||||
(Some(device), "device_discrepancy_fallback".into(), true)
|
||||
} else {
|
||||
let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0);
|
||||
let value = device * (1.0 - external_weight) + external * external_weight;
|
||||
(Some((value * 10.0).round() / 10.0), "combined".into(), false)
|
||||
}
|
||||
}
|
||||
(Some(value), None) => (Some(value), "device_fallback".into(), false),
|
||||
(None, Some(value)) => (Some(value), "external".into(), false),
|
||||
(None, None) => (None, "unavailable".into(), false),
|
||||
},
|
||||
_ => match device_temperature {
|
||||
Some(value) => (Some(value), "device".into(), false),
|
||||
None => (None, "unavailable".into(), false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn adjustment_allowed(zone: &Zone) -> bool {
|
||||
let Some(last) = zone.last_action_at else { return true; };
|
||||
(Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15)
|
||||
}
|
||||
|
||||
fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 {
|
||||
if mode == "cool" && matches!(control_source, "external" | "combined") { 0.5 } else { 0.0 }
|
||||
}
|
||||
|
||||
fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 {
|
||||
let global = global_value.clamp(30, 86_400);
|
||||
// 0 and the historical hidden default (300 s) mean "inherit the HA setting".
|
||||
// A non-default value supplied through the existing zone API remains a per-zone override.
|
||||
if zone_value == 0 || zone_value == 300 { global } else { zone_value.clamp(30, 86_400) }
|
||||
}
|
||||
|
||||
fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 {
|
||||
let value = value.clamp(16.0, 30.0);
|
||||
match (mode, demand) {
|
||||
("heat", true) => value.ceil(),
|
||||
("heat", false) => value.floor(),
|
||||
(_, true) => value.floor(),
|
||||
(_, false) => value.ceil(),
|
||||
}
|
||||
}
|
||||
|
||||
fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f64) -> f64 {
|
||||
let Some(outdoor) = outdoor else { return 0.0; };
|
||||
let room_error = (room - target).abs();
|
||||
let weather = match mode {
|
||||
"heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0),
|
||||
_ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0),
|
||||
};
|
||||
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn smart_quiet_command(
|
||||
smart_fan: bool,
|
||||
quiet_supported: bool,
|
||||
previous_demand: bool,
|
||||
demand: bool,
|
||||
device_quiet: bool,
|
||||
night_enabled: bool,
|
||||
night_active: bool,
|
||||
night_force_quiet: bool,
|
||||
) -> Option<bool> {
|
||||
if !quiet_supported { return None; }
|
||||
if night_enabled && night_force_quiet && night_active {
|
||||
return if device_quiet { None } else { Some(true) };
|
||||
}
|
||||
if night_enabled && night_force_quiet && !night_active && device_quiet {
|
||||
// Explicitly release Quiet when the scheduled night window ends.
|
||||
return Some(false);
|
||||
}
|
||||
if smart_fan {
|
||||
// Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a
|
||||
// satisfied room remains in standby: some units report Quiet=false again even after
|
||||
// accepting the command, which otherwise produces a beep every adjustment interval.
|
||||
if previous_demand && !demand && !device_quiet { return Some(true); }
|
||||
if !previous_demand && demand && device_quiet { return Some(false); }
|
||||
return None;
|
||||
}
|
||||
// Without Smart Fan, Quiet can only have been requested by scheduled night mode,
|
||||
// so release it after the night window ends.
|
||||
if night_enabled && night_force_quiet && device_quiet { return Some(false); }
|
||||
None
|
||||
}
|
||||
|
||||
fn native_sleep_command(
|
||||
night_enabled: bool,
|
||||
night_active: bool,
|
||||
use_native_sleep: bool,
|
||||
sleep_supported: bool,
|
||||
device_sleep: bool,
|
||||
) -> Option<bool> {
|
||||
if !sleep_supported { return None; }
|
||||
if night_enabled && use_native_sleep && night_active {
|
||||
return if device_sleep { None } else { Some(true) };
|
||||
}
|
||||
// If night mode ended or native Sleep was disabled in settings, remove a previously
|
||||
// active device Sleep flag instead of leaving it latched indefinitely.
|
||||
if device_sleep { return Some(false); }
|
||||
None
|
||||
}
|
||||
|
||||
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
|
||||
let max_fan = max_fan.clamp(1, 5);
|
||||
if requested == 0 { 1 } else { requested.min(max_fan) }
|
||||
}
|
||||
|
||||
pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool {
|
||||
if !settings.enabled { return false; }
|
||||
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; };
|
||||
if start == end { return true; }
|
||||
if start < end { time >= start && time < end } else { time >= start || time < end }
|
||||
}
|
||||
|
||||
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
||||
// When the thermostat is satisfied, keep airflow quiet instead of leaving the
|
||||
// unit in Auto. The caller sends this together with the standby setpoint in
|
||||
// the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating.
|
||||
if !demand { return 1; }
|
||||
let error = (room - target).abs();
|
||||
let extreme_weather = match (mode, outdoor) {
|
||||
("heat", Some(value)) => value <= 0.0,
|
||||
(_, Some(value)) => value >= 32.0,
|
||||
_ => false,
|
||||
};
|
||||
if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime<Utc>) -> bool {
|
||||
zone.temporary_quick_thermostat.as_ref()
|
||||
.and_then(|session| session.activated_at.as_ref())
|
||||
.map(|activated_at| activated_at <= &now)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
|
||||
if session.state == "paused_manual" { return None; }
|
||||
match (session.expires_at, session.safety_expires_at) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
|
||||
let hard = temporary_quick_thermostat_hard_deadline(session);
|
||||
let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 {
|
||||
session.condition_started_at.map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (hard, hold) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(b)) => Some(b),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let session = zone.temporary_quick_thermostat.as_ref()?;
|
||||
if temporary_quick_thermostat_is_active(zone, now) {
|
||||
return temporary_quick_thermostat_next_deadline(session);
|
||||
}
|
||||
// Once a due session is waiting for master/manual ownership, normal wakeups or an
|
||||
// explicit state-change notification will retry it. Returning a past start would spin.
|
||||
(session.started_at > now).then_some(session.started_at)
|
||||
}
|
||||
|
||||
/// Finish an active temporary session and apply climate changes that were deferred while
|
||||
/// it owned the zone. Pending-session cancellation should simply remove the session instead.
|
||||
pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) -> bool {
|
||||
let now = Utc::now();
|
||||
let was_active = temporary_quick_thermostat_is_active(zone, now);
|
||||
let Some(session) = zone.temporary_quick_thermostat.take() else { return false; };
|
||||
if !was_active { return false; }
|
||||
|
||||
zone.local_thermostat_power = session.restore_local_thermostat_power;
|
||||
zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at;
|
||||
zone.local_thermostat_restore_zone_enabled = session.restore_local_thermostat_zone_enabled;
|
||||
zone.manual_preset = session.restore_manual_preset;
|
||||
zone.manual_setpoint = session.restore_manual_setpoint;
|
||||
zone.manual_override_until = session.restore_manual_override_until;
|
||||
if let Some(enabled) = session.restore_zone_enabled {
|
||||
zone.enabled = enabled;
|
||||
}
|
||||
|
||||
if let Some(mode) = session.deferred_mode.as_deref() {
|
||||
match mode {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = mode.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(preset) = session.deferred_preset.as_deref() {
|
||||
if preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
} else if matches!(preset, "comfort" | "sleep" | "away") {
|
||||
zone.manual_preset = Some(preset.to_string());
|
||||
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
|
||||
}
|
||||
}
|
||||
refresh_zone_runtime_target(zone, schedules, house_mode);
|
||||
true
|
||||
}
|
||||
|
||||
async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<Vec<String>, AppError> {
|
||||
let now = Utc::now();
|
||||
let mut restored_disabled_zones = Vec::new();
|
||||
for zone in zones.iter_mut() {
|
||||
let zone_id = zone.id.clone();
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
*zone = latest;
|
||||
let active_under_manual = zone.device_manual_override
|
||||
&& zone.temporary_quick_thermostat.as_ref().and_then(|session| session.activated_at).is_some();
|
||||
if active_under_manual {
|
||||
set_temporary_wait_state(state, zone, "paused_manual", now)?;
|
||||
continue;
|
||||
}
|
||||
let Some((deadline, finish_kind, restore_zone_enabled)) = zone.temporary_quick_thermostat.as_ref()
|
||||
.and_then(|session| temporary_quick_thermostat_hard_deadline(session)
|
||||
.map(|deadline| (deadline, session.finish_kind.clone(), session.restore_zone_enabled)))
|
||||
else { continue; };
|
||||
if deadline > now { continue; }
|
||||
let was_activated = temporary_quick_thermostat_is_active(zone, now);
|
||||
let restores_disabled = was_activated && restore_zone_enabled == Some(false);
|
||||
if was_activated {
|
||||
finish_temporary_quick_thermostat(zone, schedules, house_mode);
|
||||
} else {
|
||||
// A delayed session that expires before it acquires ownership must not clear
|
||||
// unrelated local/manual/schedule state that was active while it was waiting.
|
||||
zone.temporary_quick_thermostat = None;
|
||||
}
|
||||
zone.updated_at = now;
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind,
|
||||
"reason": if was_activated { "deadline" } else { "expired_before_activation" }
|
||||
}));
|
||||
if restores_disabled { restored_disabled_zones.push(zone.id.clone()); }
|
||||
}
|
||||
Ok(restored_disabled_zones)
|
||||
}
|
||||
|
||||
fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now: DateTime<Utc>) -> Result<(), AppError> {
|
||||
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return Ok(()); };
|
||||
let mut changed = false;
|
||||
if session.state != value {
|
||||
session.state = value.to_string();
|
||||
changed = true;
|
||||
}
|
||||
if value == "paused_manual" && session.paused_at.is_none() {
|
||||
session.paused_at = Some(now);
|
||||
changed = true;
|
||||
}
|
||||
if !changed { return Ok(()); }
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
zone.updated_at = now;
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn activate_due_temporary_quick_thermostats(
|
||||
state: &AppState,
|
||||
zones: &mut [Zone],
|
||||
schedules: &[Schedule],
|
||||
house_mode: &str,
|
||||
house_power_enabled: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
for zone in zones.iter_mut() {
|
||||
let zone_id = zone.id.clone();
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
*zone = latest;
|
||||
let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref()
|
||||
.map(|session| (session.state.clone(), session.started_at))
|
||||
else { continue; };
|
||||
if temporary_quick_thermostat_is_active(zone, now) {
|
||||
if session_state != "active" && !zone.device_manual_override {
|
||||
set_temporary_wait_state(state, zone, "active", now)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if started_at > now { continue; }
|
||||
if !house_power_enabled {
|
||||
set_temporary_wait_state(state, zone, "waiting_master", now)?;
|
||||
continue;
|
||||
}
|
||||
if zone.device_manual_override {
|
||||
set_temporary_wait_state(state, zone, "paused_manual", now)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = {
|
||||
let session = zone.temporary_quick_thermostat.as_ref().expect("temporary session checked above");
|
||||
(session.temperature_target, session.duration_seconds, session.safety_duration_seconds, session.finish_kind.clone())
|
||||
};
|
||||
let target = temperature_target
|
||||
.or(zone.manual_setpoint)
|
||||
.or(zone.effective_setpoint)
|
||||
.unwrap_or(zone.setpoint);
|
||||
let restore_enabled = zone.enabled;
|
||||
let restore_local_power = zone.local_thermostat_power;
|
||||
let restore_local_resume_at = zone.local_thermostat_resume_at;
|
||||
let restore_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
|
||||
let restore_manual_preset = zone.manual_preset.clone();
|
||||
let restore_manual_setpoint = zone.manual_setpoint;
|
||||
let restore_manual_override_until = zone.manual_override_until;
|
||||
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
|
||||
let active_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() };
|
||||
let schedule_boundary = if finish_kind == "schedule_boundary" {
|
||||
next_schedule_boundary_utc(&zone.id, schedules, Local::now())
|
||||
} else { None };
|
||||
if finish_kind == "schedule_boundary" && schedule_boundary.is_none() {
|
||||
zone.temporary_quick_thermostat = None;
|
||||
zone.updated_at = now;
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("warn", "zone.temporary_quick_thermostat_cancelled", &format!("Temporary Quick Thermostat cancelled for {} because no future schedule boundary exists", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Temporary ownership is independent from the ordinary local hand-back state.
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.local_thermostat_resume_at = None;
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.enabled = true;
|
||||
zone.manual_setpoint = Some(target);
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.manual_override_until = None;
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.activated_at = Some(now);
|
||||
session.state = "active".into();
|
||||
session.active_mode = Some(active_mode);
|
||||
session.restore_zone_enabled = Some(restore_enabled);
|
||||
session.restore_local_thermostat_power = restore_local_power;
|
||||
session.restore_local_thermostat_resume_at = restore_local_resume_at;
|
||||
session.restore_local_thermostat_zone_enabled = restore_local_zone_enabled;
|
||||
session.restore_manual_preset = restore_manual_preset;
|
||||
session.restore_manual_setpoint = restore_manual_setpoint;
|
||||
session.restore_manual_override_until = restore_manual_override_until;
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
session.paused_at = None;
|
||||
if session.finish_kind == "duration" {
|
||||
session.expires_at = duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64));
|
||||
} else if session.finish_kind == "schedule_boundary" {
|
||||
session.expires_at = schedule_boundary;
|
||||
}
|
||||
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
|
||||
session.safety_expires_at = safety_duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64));
|
||||
}
|
||||
}
|
||||
zone.updated_at = now;
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.temporary_quick_thermostat_started", &format!("Temporary Quick Thermostat started for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "scheduled_start": true, "target_temperature": target
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) {
|
||||
if zone.enabled || zone.device_manual_override || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; }
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
let should_stop = state.db.get_zone(&zone.id).ok().flatten()
|
||||
.map(|latest| !latest.enabled
|
||||
&& !latest.device_manual_override
|
||||
&& latest.temporary_quick_thermostat.is_none()
|
||||
&& latest.local_thermostat_power.is_none())
|
||||
.unwrap_or(false);
|
||||
if !should_stop { return; }
|
||||
if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool {
|
||||
let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; };
|
||||
let tolerance = session.tolerance_c.max(0.0);
|
||||
match session.temperature_operator.as_deref().unwrap_or("within") {
|
||||
"at_or_below" => current <= target + tolerance,
|
||||
"at_or_above" => current >= target - tolerance,
|
||||
_ => (current - target).abs() <= tolerance,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a temperature-based temporary session only from a fresh sensor observation.
|
||||
/// Cached samples and long controller gaps cannot count as continuous hold time.
|
||||
fn evaluate_temporary_quick_thermostat_condition(
|
||||
zone: &mut Zone,
|
||||
now: DateTime<Utc>,
|
||||
sample_at: Option<DateTime<Utc>>,
|
||||
max_gap_seconds: u64,
|
||||
) -> Option<String> {
|
||||
if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { return None; }
|
||||
let is_condition = zone.temporary_quick_thermostat.as_ref()
|
||||
.map(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable"))
|
||||
.unwrap_or(false);
|
||||
if !is_condition { return None; }
|
||||
|
||||
let Some(sample_at) = sample_at else {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let last_observed = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.condition_last_observed_at);
|
||||
if last_observed.map(|last| sample_at <= last).unwrap_or(false) { return None; }
|
||||
let gap_broken = last_observed
|
||||
.map(|last| sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64)
|
||||
.unwrap_or(false);
|
||||
let met = zone.temporary_quick_thermostat.as_ref()
|
||||
.map(|session| temporary_temperature_condition_met(zone, session))?;
|
||||
|
||||
let session = zone.temporary_quick_thermostat.as_mut()?;
|
||||
session.condition_last_observed_at = Some(sample_at);
|
||||
if gap_broken { session.condition_started_at = None; }
|
||||
match session.finish_kind.as_str() {
|
||||
"temperature_reached" => {
|
||||
if met { return Some("temperature_reached".into()); }
|
||||
session.condition_started_at = None;
|
||||
}
|
||||
"temperature_stable" => {
|
||||
if !met {
|
||||
session.condition_started_at = None;
|
||||
return None;
|
||||
}
|
||||
let started = session.condition_started_at.get_or_insert(sample_at);
|
||||
if session.hold_seconds == 0 || sample_at.signed_duration_since(*started).num_seconds() >= session.hold_seconds as i64 {
|
||||
return Some("temperature_stable".into());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
for zone in zones.iter_mut() {
|
||||
let zone_id = zone.id.clone();
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
|
||||
*zone = latest;
|
||||
// A direct device/pilot takeover has higher priority than the local-OFF hand-back.
|
||||
// Do not let the old timer expire underneath someone who is actively controlling
|
||||
// the unit. When that takeover ends and the device returns to OFF, the deadline is
|
||||
// re-armed from that moment.
|
||||
if !local_thermostat_handback_is_active(zone) { continue; }
|
||||
if zone.local_thermostat_resume_at.is_none() {
|
||||
// Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases
|
||||
// had no hand-back deadline, so start one from the first cycle after upgrade.
|
||||
set_local_thermostat_power(zone, false, now.clone());
|
||||
zone.updated_at = now.clone();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false);
|
||||
if !expired { continue; }
|
||||
reset_local_thermostat_override(zone);
|
||||
refresh_zone_runtime_target(zone, schedules, house_mode);
|
||||
zone.updated_at = now.clone();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.local_thermostat_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn global_ha_sensor_stale_timeout_is_used_for_default_zone_value() {
|
||||
assert_eq!(effective_sensor_stale_after_seconds(300, 600), 600);
|
||||
assert_eq!(effective_sensor_stale_after_seconds(0, 900), 900);
|
||||
assert_eq!(effective_sensor_stale_after_seconds(120, 600), 120);
|
||||
assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overnight_schedule_works() {
|
||||
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
|
||||
let item = Schedule {
|
||||
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
|
||||
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
};
|
||||
assert!(schedule_active(&item, now));
|
||||
}
|
||||
|
||||
fn test_schedule(id: &str, weekdays: Vec<u32>, start: &str, end: &str) -> Schedule {
|
||||
Schedule {
|
||||
id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true,
|
||||
weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_schedule_times_mean_a_full_day() {
|
||||
let item = test_schedule("full", vec![1], "06:00", "06:00");
|
||||
let monday_noon = Local.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).single().unwrap();
|
||||
let tuesday_early = Local.with_ymd_and_hms(2025, 1, 7, 5, 59, 0).single().unwrap();
|
||||
let tuesday_after = Local.with_ymd_and_hms(2025, 1, 7, 6, 1, 0).single().unwrap();
|
||||
assert!(schedule_active(&item, monday_noon));
|
||||
assert!(schedule_active(&item, tuesday_early));
|
||||
assert!(!schedule_active(&item, tuesday_after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schedule_overlap_detection_handles_overnight_ranges() {
|
||||
let daytime = test_schedule("day", vec![1,2,3,4,5,6,7], "06:30", "22:30");
|
||||
let night = test_schedule("night", vec![1,2,3,4,5,6,7], "22:30", "06:30");
|
||||
let conflict = test_schedule("conflict", vec![1], "22:00", "23:00");
|
||||
assert!(!schedules_overlap(&daytime, &night));
|
||||
assert!(schedules_overlap(&night, &conflict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_schedule_boundary_scans_the_whole_week() {
|
||||
let friday = test_schedule("friday", vec![5], "12:00", "13:00");
|
||||
let monday = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
||||
let boundary = next_schedule_boundary_utc("z", &[friday], monday).unwrap().with_timezone(&Local);
|
||||
assert_eq!(boundary.weekday(), Weekday::Fri);
|
||||
assert_eq!(boundary.hour(), 12);
|
||||
assert_eq!(boundary.minute(), 0);
|
||||
assert_eq!(boundary.second(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_week_schedule_has_no_manual_override_boundary() {
|
||||
let always = test_schedule("always", vec![1,2,3,4,5,6,7], "00:00", "00:00");
|
||||
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
||||
assert!(next_schedule_boundary_utc("z", &[always], now).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workday_weekend_handoff_has_no_overlaps() {
|
||||
let schedules = vec![
|
||||
test_schedule("morning", vec![1,2,3,4,5], "06:30", "08:00"),
|
||||
test_schedule("away", vec![1,2,3,4,5], "08:00", "16:00"),
|
||||
test_schedule("evening", vec![1,2,3,4,5], "16:00", "22:30"),
|
||||
test_schedule("sleep", vec![1,2,3,4,5], "22:30", "06:30"),
|
||||
test_schedule("weekend", vec![6,7], "08:00", "23:00"),
|
||||
test_schedule("saturday-sleep", vec![6], "23:00", "08:00"),
|
||||
test_schedule("sunday-sleep", vec![7], "23:00", "06:30"),
|
||||
];
|
||||
for (index, item) in schedules.iter().enumerate() {
|
||||
for other in schedules.iter().skip(index + 1) {
|
||||
assert!(!schedules_overlap(item, other), "{} overlaps {}", item.name, other.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_automation_fires_only_once_in_the_same_minute() {
|
||||
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap();
|
||||
let mut item = Automation {
|
||||
id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(),
|
||||
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
||||
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
||||
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
||||
last_fired_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
};
|
||||
assert!(time_automation_due(&item, now.clone()));
|
||||
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
||||
assert!(!time_automation_due(&item, now));
|
||||
}
|
||||
|
||||
fn test_zone(source: &str) -> Zone {
|
||||
Zone {
|
||||
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
||||
mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1,
|
||||
cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0,
|
||||
heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0,
|
||||
hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true,
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None,
|
||||
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
|
||||
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
|
||||
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_device_change_detects_manual_climate_controls() {
|
||||
let zone = test_zone("device");
|
||||
let before = Device::simulated_default();
|
||||
let mut after = before.clone();
|
||||
after.power = !before.power;
|
||||
after.target_temperature = before.target_temperature + 1.0;
|
||||
after.fan_speed = 3;
|
||||
let fields = externally_changed_control_fields(&before, &after, &zone);
|
||||
assert!(fields.iter().any(|field| field == "power"));
|
||||
assert!(fields.iter().any(|field| field == "target_temperature"));
|
||||
assert!(fields.iter().any(|field| field == "fan_speed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controller_expected_climate_change_is_recognized() {
|
||||
let mut device = Device::simulated_default();
|
||||
device.power = true;
|
||||
device.mode = "cool".into();
|
||||
device.target_temperature = 22.0;
|
||||
let command = DeviceCommand {
|
||||
power: Some(true),
|
||||
mode: Some("cool".into()),
|
||||
target_temperature: Some(22.0),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(command_field_matches_device(&command, "power", &device));
|
||||
assert!(command_field_matches_device(&command, "mode", &device));
|
||||
assert!(command_field_matches_device(&command, "target_temperature", &device));
|
||||
device.target_temperature = 25.0;
|
||||
assert!(!command_field_matches_device(&command, "target_temperature", &device));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_ownership_blocks_direct_automation() {
|
||||
let mut zone = test_zone("device");
|
||||
assert!(!device_blocked_by_local_thermostat(
|
||||
&zone.device_id,
|
||||
std::slice::from_ref(&zone),
|
||||
));
|
||||
zone.local_thermostat_power = Some(true);
|
||||
assert!(device_blocked_by_local_thermostat(
|
||||
&zone.device_id,
|
||||
std::slice::from_ref(&zone),
|
||||
));
|
||||
zone.local_thermostat_power = Some(false);
|
||||
assert!(device_blocked_by_local_thermostat(
|
||||
&zone.device_id,
|
||||
std::slice::from_ref(&zone),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_resume_clears_only_local_quick_control_state() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(false);
|
||||
zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15));
|
||||
zone.manual_preset = Some("comfort".into());
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1));
|
||||
zone.device_manual_override = true;
|
||||
|
||||
assert!(reset_local_thermostat_override(&mut zone));
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert!(zone.local_thermostat_resume_at.is_none());
|
||||
assert!(zone.manual_preset.is_none());
|
||||
assert!(zone.manual_setpoint.is_none());
|
||||
assert!(zone.manual_override_until.is_none());
|
||||
assert!(zone.device_manual_override);
|
||||
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
|
||||
}
|
||||
|
||||
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
|
||||
TemporaryQuickThermostat {
|
||||
start_kind: "now".into(),
|
||||
finish_kind: "temperature_stable".into(),
|
||||
started_at: now.clone(),
|
||||
activated_at: Some(now),
|
||||
state: "active".into(),
|
||||
active_mode: Some("heat".into()),
|
||||
restore_zone_enabled: Some(true),
|
||||
restore_local_thermostat_power: None,
|
||||
restore_local_thermostat_resume_at: None,
|
||||
restore_local_thermostat_zone_enabled: None,
|
||||
restore_manual_preset: None,
|
||||
restore_manual_setpoint: None,
|
||||
restore_manual_override_until: None,
|
||||
expires_at: None,
|
||||
duration_seconds: None,
|
||||
safety_duration_seconds: None,
|
||||
temperature_target: Some(23.0),
|
||||
temperature_operator: Some("within".into()),
|
||||
tolerance_c: 0.3,
|
||||
hold_seconds: 3600,
|
||||
condition_started_at: None,
|
||||
condition_last_observed_at: None,
|
||||
paused_at: None,
|
||||
deferred_mode: None,
|
||||
deferred_preset: None,
|
||||
safety_expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_temporary_session_is_not_activated_by_unrelated_local_quick_on() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
let mut session = temporary_session(now + chrono::Duration::hours(1));
|
||||
session.start_kind = "delay".into();
|
||||
session.activated_at = None;
|
||||
session.state = "scheduled".into();
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(!temporary_quick_thermostat_is_active(&zone, now));
|
||||
assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now), Some(now + chrono::Duration::hours(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_handback_cleanup_does_not_remove_pending_temporary_session() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
set_local_thermostat_power(&mut zone, false, now);
|
||||
let mut session = temporary_session(now + chrono::Duration::minutes(30));
|
||||
session.start_kind = "delay".into();
|
||||
session.activated_at = None;
|
||||
session.state = "scheduled".into();
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
reset_local_thermostat_override(&mut zone);
|
||||
assert!(zone.temporary_quick_thermostat.is_some());
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_quick_thermostat_restores_previous_zone_enabled_state() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.enabled = true;
|
||||
zone.local_thermostat_power = Some(true);
|
||||
let mut session = temporary_session(now);
|
||||
session.restore_zone_enabled = Some(false);
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
|
||||
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat"));
|
||||
assert!(!zone.enabled);
|
||||
assert!(zone.local_thermostat_power.is_none());
|
||||
assert!(zone.temporary_quick_thermostat.is_none());
|
||||
assert!(zone.manual_setpoint.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_quick_thermostat_returns_to_underlying_local_quick_state() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.enabled = true;
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.local_thermostat_restore_zone_enabled = None;
|
||||
zone.manual_setpoint = Some(24.0);
|
||||
let mut session = temporary_session(now);
|
||||
session.restore_zone_enabled = Some(true);
|
||||
session.restore_local_thermostat_power = Some(true);
|
||||
session.restore_local_thermostat_zone_enabled = Some(false);
|
||||
session.restore_manual_setpoint = Some(22.0);
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
|
||||
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat"));
|
||||
assert!(zone.enabled);
|
||||
assert_eq!(zone.local_thermostat_power, Some(true));
|
||||
assert_eq!(zone.local_thermostat_restore_zone_enabled, Some(false));
|
||||
assert_eq!(zone.manual_setpoint, Some(22.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_session_without_activation_marker_is_never_active() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
let mut session = temporary_session(now);
|
||||
session.activated_at = None;
|
||||
session.state = "scheduled".into();
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(!temporary_quick_thermostat_is_active(&zone, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_stable_condition_requires_continuous_hold_time() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(23.2);
|
||||
let mut session = temporary_session(now.clone());
|
||||
session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone(), Some(now.clone()), 10).is_none());
|
||||
assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2), Some(now + chrono::Duration::seconds(2)), 10), Some("temperature_stable".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_stable_condition_resets_when_temperature_leaves_range() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(24.0);
|
||||
let mut session = temporary_session(now.clone());
|
||||
session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none());
|
||||
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_stable_condition_resets_after_observation_gap() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(23.0);
|
||||
let mut session = temporary_session(now);
|
||||
session.condition_started_at = Some(now - chrono::Duration::hours(1));
|
||||
session.condition_last_observed_at = Some(now - chrono::Duration::minutes(10));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 30).is_none());
|
||||
assert_eq!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at, Some(now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_temporary_session_does_not_block_automation_before_start() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
|
||||
session.start_kind = "delay".into();
|
||||
session.activated_at = None;
|
||||
session.state = "scheduled".into();
|
||||
session.expires_at = Some(now.clone() + chrono::Duration::hours(3));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(!device_blocked_by_local_thermostat(&zone.device_id, std::slice::from_ref(&zone)));
|
||||
assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now.clone()), Some(now + chrono::Duration::hours(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_temperature_condition_cannot_finish_before_activation() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(23.0);
|
||||
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
|
||||
session.start_kind = "at".into();
|
||||
session.activated_at = None;
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10).is_none());
|
||||
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_setpoint_keeps_priority_over_active_schedule() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
zone.temporary_quick_thermostat = Some(temporary_session(Utc::now()));
|
||||
let mut schedule = test_schedule("night", vec![1,2,3,4,5,6,7], "00:00", "00:00");
|
||||
schedule.preset = "custom".into();
|
||||
schedule.setpoint = 19.0;
|
||||
|
||||
let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool");
|
||||
assert_eq!(target, 23.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.inherit_house_mode = true;
|
||||
zone.mode = "cool".into();
|
||||
assert_eq!(effective_zone_mode(&zone, "off"), "off");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_off_restarts_backend_handback_deadline() {
|
||||
let mut zone = test_zone("device");
|
||||
let first = Utc::now();
|
||||
set_local_thermostat_power(&mut zone, false, first.clone());
|
||||
let first_deadline = zone.local_thermostat_resume_at.clone().unwrap();
|
||||
assert_eq!(first_deadline, first.clone() + chrono::Duration::minutes(15));
|
||||
|
||||
let second = first + chrono::Duration::minutes(4);
|
||||
set_local_thermostat_power(&mut zone, true, second.clone());
|
||||
assert!(zone.local_thermostat_resume_at.is_none());
|
||||
set_local_thermostat_power(&mut zone, false, second.clone());
|
||||
assert_eq!(zone.local_thermostat_resume_at, Some(second + chrono::Duration::minutes(15)));
|
||||
assert!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_device_takeover_suspends_local_handback_until_control_returns() {
|
||||
let mut zone = test_zone("device");
|
||||
let now = Utc::now();
|
||||
set_local_thermostat_power(&mut zone, false, now.clone());
|
||||
assert!(local_thermostat_handback_is_active(&zone));
|
||||
|
||||
zone.device_manual_override = true;
|
||||
assert!(!local_thermostat_handback_is_active(&zone));
|
||||
|
||||
zone.device_manual_override = false;
|
||||
let returned = now + chrono::Duration::minutes(7);
|
||||
assert!(rearm_local_thermostat_resume(&mut zone, returned.clone()));
|
||||
assert_eq!(zone.local_thermostat_resume_at, Some(returned + chrono::Duration::minutes(15)));
|
||||
assert!(local_thermostat_handback_is_active(&zone));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_gree_setpoint_does_not_create_manual_override() {
|
||||
let zone = test_zone("device");
|
||||
let mut before = Device::simulated_default();
|
||||
before.target_temperature = 23.5;
|
||||
let mut after = before.clone();
|
||||
after.target_temperature = 24.0;
|
||||
assert!(externally_changed_control_fields(&before, &after, &zone).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standby_low_to_auto_fan_drift_is_not_manual_override() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.smart_fan = true;
|
||||
zone.demand = false;
|
||||
let mut before = Device::simulated_default();
|
||||
before.fan_speed = 1;
|
||||
let mut after = before.clone();
|
||||
after.fan_speed = 0;
|
||||
assert!(externally_changed_control_fields(&before, &after, &zone).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_device_manual_override_clears_takeover_state() {
|
||||
let mut zone = test_zone("device");
|
||||
let device = Device::simulated_default();
|
||||
zone.device_manual_override = true;
|
||||
zone.device_manual_override_since = Some(Utc::now());
|
||||
zone.device_manual_override_until = Some(Utc::now());
|
||||
zone.device_manual_override_fields = vec!["target_temperature".into()];
|
||||
zone.device_manual_override_baseline = Some((&device).into());
|
||||
assert!(reset_device_manual_override(&mut zone));
|
||||
assert!(!zone.device_manual_override);
|
||||
assert!(zone.device_manual_override_since.is_none());
|
||||
assert!(zone.device_manual_override_until.is_none());
|
||||
assert!(zone.device_manual_override_fields.is_empty());
|
||||
assert!(zone.device_manual_override_baseline.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_manual_device_state_matches_original_takeover_baseline() {
|
||||
let mut zone = test_zone("device");
|
||||
let baseline = Device::simulated_default();
|
||||
zone.device_manual_override = true;
|
||||
zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()];
|
||||
zone.device_manual_override_baseline = Some((&baseline).into());
|
||||
|
||||
let mut changed = baseline.clone();
|
||||
changed.power = !baseline.power;
|
||||
changed.target_temperature = baseline.target_temperature + 2.0;
|
||||
assert!(!manual_override_matches_baseline(&zone, &changed));
|
||||
|
||||
let mut returned_off = baseline.clone();
|
||||
returned_off.target_temperature = baseline.target_temperature + 3.0;
|
||||
assert!(manual_override_matches_baseline(&zone, &returned_off));
|
||||
assert!(manual_override_matches_baseline(&zone, &baseline));
|
||||
}
|
||||
|
||||
#[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");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0));
|
||||
assert_eq!(value, Some(21.2));
|
||||
assert_eq!(source, "combined");
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_falls_back_on_large_discrepancy() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0));
|
||||
assert_eq!(value, Some(21.0));
|
||||
assert_eq!(source, "device_discrepancy_fallback");
|
||||
assert!(discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_falls_back_when_external_is_missing() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None);
|
||||
assert_eq!(value, Some(21.5));
|
||||
assert_eq!(source, "device_fallback");
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonal_profiles_resolve_independently() {
|
||||
let zone = test_zone("device");
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0);
|
||||
assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5);
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0);
|
||||
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_setpoint_keeps_active_preset() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.manual_setpoint = Some(22.5);
|
||||
let (preset, target) = resolve_zone_target(&zone, None, "cool");
|
||||
assert_eq!(preset, "comfort");
|
||||
assert_eq!(target, 22.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_target_refresh_applies_manual_profile_immediately() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = "cool".into();
|
||||
zone.manual_preset = Some("sleep".into());
|
||||
zone.active_preset = "comfort".into();
|
||||
zone.effective_setpoint = Some(25.0);
|
||||
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
||||
assert_eq!(zone.active_preset, "sleep");
|
||||
assert_eq!(zone.effective_setpoint, Some(24.5));
|
||||
assert_eq!(zone.effective_mode, "cool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_zone_keeps_old_comfort_setpoint() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.profile_version = 0;
|
||||
zone.setpoint = 22.5;
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5);
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_setpoint_rounding_preserves_control_direction() {
|
||||
assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0);
|
||||
assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0);
|
||||
assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0);
|
||||
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() {
|
||||
assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 0.5);
|
||||
assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 0.5);
|
||||
assert_eq!(external_room_sensor_cooling_assist("cool", "device_fallback"), 0.0);
|
||||
assert_eq!(external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"), 0.0);
|
||||
assert_eq!(external_room_sensor_cooling_assist("heat", "external"), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_fan_uses_low_speed_when_zone_is_satisfied() {
|
||||
assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1);
|
||||
assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
|
||||
assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true));
|
||||
assert_eq!(smart_quiet_command(true, true, false, false, false, false, false, true), None);
|
||||
assert_eq!(smart_quiet_command(true, true, false, false, true, false, false, true), None);
|
||||
assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false));
|
||||
assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None);
|
||||
assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None);
|
||||
assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn night_mode_handles_midnight_and_limits_auto_fan() {
|
||||
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true };
|
||||
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
|
||||
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
|
||||
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
|
||||
assert_eq!(night_limited_fan_speed(0, 1), 1);
|
||||
assert_eq!(night_limited_fan_speed(3, 1), 1);
|
||||
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
|
||||
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
|
||||
assert_eq!(smart_quiet_command(true, true, false, false, true, true, false, true), Some(false));
|
||||
assert_eq!(native_sleep_command(true, true, true, true, false), Some(true));
|
||||
assert_eq!(native_sleep_command(true, false, true, true, true), Some(false));
|
||||
assert_eq!(native_sleep_command(false, false, false, true, true), Some(false));
|
||||
assert_eq!(native_sleep_command(true, true, true, false, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gree_outdoor_fallback_uses_median_of_online_units() {
|
||||
let mut a = Device::simulated_default();
|
||||
a.outdoor_temperature = Some(10.0);
|
||||
let mut b = Device::simulated_default();
|
||||
b.id = "sim-b".into();
|
||||
b.outdoor_temperature = Some(12.0);
|
||||
let mut c = Device::simulated_default();
|
||||
c.id = "sim-c".into();
|
||||
c.outdoor_temperature = Some(40.0);
|
||||
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdoor_assist_is_bounded_and_direction_neutral() {
|
||||
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
|
||||
let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0);
|
||||
assert!(cool > 0.0 && cool <= 1.0);
|
||||
assert!(heat > 0.0 && heat <= 1.0);
|
||||
assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateTime<Utc>) -> Result<Zone, AppError> {
|
||||
let Some(mut latest) = state.db.get_zone(&computed.id)? else { return Ok(computed.clone()); };
|
||||
if latest.updated_at <= cycle_started_at {
|
||||
state.db.save_zone(computed)?;
|
||||
return Ok(computed.clone());
|
||||
}
|
||||
// Another actor changed this zone while the regulator was doing network I/O. Never
|
||||
// write the old controller snapshot over fresh configuration or manual takeover state.
|
||||
// Sensor observations are safe to carry forward only while the device assignment matches.
|
||||
if latest.device_id == computed.device_id {
|
||||
latest.device_temperature = computed.device_temperature;
|
||||
latest.external_temperature = computed.external_temperature;
|
||||
latest.current_temperature = computed.current_temperature;
|
||||
latest.control_temperature_source = computed.control_temperature_source.clone();
|
||||
latest.updated_at = Utc::now();
|
||||
state.db.save_zone(&latest)?;
|
||||
}
|
||||
Ok(latest)
|
||||
}
|
||||
|
||||
async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
||||
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
||||
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
||||
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); }
|
||||
if zone.local_thermostat_power == Some(true) { return Ok(true); }
|
||||
let blocked = state.db.list_groups()?.iter().any(|group| {
|
||||
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
||||
});
|
||||
Ok(!blocked)
|
||||
}
|
||||
|
||||
async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
||||
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
||||
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
||||
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(false); }
|
||||
Ok(state.db.list_groups()?.iter().any(|group| {
|
||||
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
||||
}))
|
||||
}
|
||||
|
||||
async fn send_zone_command_if_owned(
|
||||
state: &AppState,
|
||||
zone_id: &str,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
require_group_block: bool,
|
||||
) -> Result<Option<Device>, AppError> {
|
||||
// Ownership must be checked after acquiring the same per-device lock used by polling.
|
||||
// Otherwise polling could detect a remote takeover while this task is waiting for the lock,
|
||||
// and a stale thermostat decision would still be sent immediately afterwards.
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
let owned = if require_group_block {
|
||||
group_off_ownership_is_current(state, zone_id, device_id).await?
|
||||
} else {
|
||||
thermostat_ownership_is_current(state, zone_id, device_id).await?
|
||||
};
|
||||
if !owned { return Ok(None); }
|
||||
send_command_locked(state, device_id, command).await.map(Some)
|
||||
}
|
||||
|
||||
async fn send_group_power_if_current(
|
||||
state: &AppState,
|
||||
group_id: &str,
|
||||
zone_id: &str,
|
||||
device_id: &str,
|
||||
desired_power: bool,
|
||||
) -> Result<Option<Device>, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); };
|
||||
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); }
|
||||
let groups = state.db.list_groups()?;
|
||||
let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); };
|
||||
if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); }
|
||||
if desired_power {
|
||||
let settings = state.settings.read().await;
|
||||
if !settings.house_power_enabled { return Ok(None); }
|
||||
let effective_mode = if zone.inherit_house_mode { settings.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
if effective_mode == "off" { return Ok(None); }
|
||||
if groups.iter().any(|other| {
|
||||
other.id != group_id && !other.power_enabled && other.zone_ids.iter().any(|member| member == zone_id)
|
||||
}) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let Some(device) = state.db.get_device(device_id)? else { return Ok(None); };
|
||||
if !device.enabled { return Ok(None); }
|
||||
let command = DeviceCommand { power: Some(desired_power), ..Default::default() };
|
||||
if desired_power {
|
||||
send_command_locked(state, device_id, command).await.map(Some)
|
||||
} else {
|
||||
// A deliberate group OFF is a one-shot safety transition. Send it even if the
|
||||
// cached state already says OFF; the regulator itself will not keep repeating it.
|
||||
send_command_locked_forced(state, device_id, command).await.map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_automatic_device_command_if_owned(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
) -> Result<Option<Device>, AppError> {
|
||||
let _device_guard = state.lock_device_operation(device_id).await;
|
||||
if !state.settings.read().await.house_power_enabled { return Ok(None); }
|
||||
let zones = state.db.list_zones()?;
|
||||
if device_blocked_by_disabled_zone(device_id, &zones)
|
||||
|| device_blocked_by_manual_override(device_id, &zones)
|
||||
|| device_blocked_by_local_thermostat(device_id, &zones)
|
||||
|| device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
send_command_locked(state, device_id, command).await.map(Some)
|
||||
}
|
||||
|
||||
async fn apply_automatic_device_action(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
) -> Result<Option<Device>, AppError> {
|
||||
let zones = state.db.list_zones()?;
|
||||
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else {
|
||||
return send_automatic_device_command_if_owned(state, device_id, command).await;
|
||||
};
|
||||
|
||||
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
||||
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let groups = state.db.list_groups()?;
|
||||
if groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|member| member == &zone.id)) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A power-on automation is an explicit domain transition and may re-enable a zone that
|
||||
// a previous power automation disabled. Other actions still respect a disabled zone gate.
|
||||
if !zone.enabled && command.power != Some(true) { return Ok(None); }
|
||||
|
||||
let mut domain_changed = false;
|
||||
if let Some(power) = command.power {
|
||||
zone.enabled = power;
|
||||
domain_changed = true;
|
||||
}
|
||||
if let Some(mode) = command.mode.as_deref() {
|
||||
match mode {
|
||||
"heat" | "cool" => {
|
||||
zone.mode = mode.to_string();
|
||||
zone.inherit_house_mode = false;
|
||||
domain_changed = true;
|
||||
}
|
||||
"auto" => {
|
||||
zone.inherit_house_mode = true;
|
||||
domain_changed = true;
|
||||
}
|
||||
_ => return Err(AppError::BadRequest(
|
||||
"device automation for a thermostat-managed unit supports only heat, cool or auto mode".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
if let Some(target) = command.target_temperature {
|
||||
zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0);
|
||||
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
|
||||
domain_changed = true;
|
||||
}
|
||||
|
||||
if domain_changed {
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
zone.control_source = "automation.device".into();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.wake_zone_control();
|
||||
}
|
||||
|
||||
if command.power == Some(false) {
|
||||
return force_power_off_device(state, device_id).await.map(Some);
|
||||
}
|
||||
|
||||
// Climate fields above are durable zone state. Only non-climate device capabilities remain
|
||||
// a one-shot command; the thermostat can no longer undo power/mode/target next cycle.
|
||||
let residual = DeviceCommand {
|
||||
power: None,
|
||||
mode: None,
|
||||
target_temperature: None,
|
||||
fan_speed: command.fan_speed,
|
||||
swing_vertical: command.swing_vertical,
|
||||
swing_horizontal: command.swing_horizontal,
|
||||
quiet: command.quiet,
|
||||
turbo: command.turbo,
|
||||
light: command.light,
|
||||
air: command.air,
|
||||
xfan: command.xfan,
|
||||
health: command.health,
|
||||
sleep: command.sleep,
|
||||
};
|
||||
if residual.is_empty() {
|
||||
return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}")));
|
||||
}
|
||||
send_automatic_device_command_if_owned(state, device_id, residual).await
|
||||
}
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut zone_snapshot = state.db.list_zones()?;
|
||||
// Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker
|
||||
// before the house-power early return so the hand-back still happens while the master
|
||||
// is off; no physical state is restored here, only automation ownership.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
||||
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, settings.house_power_enabled).await?;
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
||||
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
|
||||
let device_snapshot = state.db.list_devices()?;
|
||||
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
|
||||
let resolved_outdoor = if configured_outdoor.is_empty() {
|
||||
None
|
||||
} else {
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
|
||||
};
|
||||
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await {
|
||||
Ok(value) => {
|
||||
record_ha_history(
|
||||
state,
|
||||
entity_id,
|
||||
None,
|
||||
"outdoor",
|
||||
value,
|
||||
settings.poll_interval_seconds,
|
||||
);
|
||||
Some(value)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
|
||||
{
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != outdoor_temperature {
|
||||
*current = outdoor_temperature;
|
||||
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
|
||||
}
|
||||
}
|
||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||
|
||||
if !settings.house_power_enabled {
|
||||
// Whole-house OFF is a one-shot action performed by the API endpoint. While the
|
||||
// master remains off the regulator stays passive. A later physical/remote change
|
||||
// is therefore detected as manual takeover and is not erased or forced OFF again.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost
|
||||
// one request timeout per cycle, not one timeout multiplied by the number of zones.
|
||||
let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| {
|
||||
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; }
|
||||
let zone_id = zone.id.clone();
|
||||
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
|
||||
let http = &state.http;
|
||||
let ha_settings = &settings.home_assistant;
|
||||
let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds);
|
||||
Some(async move {
|
||||
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await
|
||||
.map_err(|err| err.to_string());
|
||||
(zone_id, resolved_entity, result)
|
||||
})
|
||||
})).await;
|
||||
let mut room_sensor_results: HashMap<String, (Option<String>, Result<f64, String>)> = room_sensor_reads.into_iter()
|
||||
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
|
||||
.collect();
|
||||
|
||||
for mut zone in zone_snapshot {
|
||||
let cycle_started_at = zone.updated_at;
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
reset_device_manual_override(&mut zone);
|
||||
state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id
|
||||
}));
|
||||
}
|
||||
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else {
|
||||
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
|
||||
continue;
|
||||
};
|
||||
|
||||
// House "off" means the smart thermostat does not control inherited zones.
|
||||
// A zone explicitly switched to heat/cool remains independent and may still run.
|
||||
// Local Quick Thermostat is an explicit per-zone request. If the inherited house
|
||||
// climate mode is "off" (no automatic climate control), use the zone's last local
|
||||
// heat/cool mode while local ownership is ON. The separate whole-house master power
|
||||
// remains authoritative and is checked before this loop.
|
||||
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
||||
zone.effective_mode = effective_mode_owned.clone();
|
||||
let ownership_blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group);
|
||||
let effective_mode = effective_mode_owned.as_str();
|
||||
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
// Never feed the thermostat a cached GREE temperature after any communication
|
||||
// failure. External HA sensors may still keep a zone operational when configured.
|
||||
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
|
||||
device.current_temperature
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match room_sensor_results.remove(&zone.id) {
|
||||
Some((resolved_entity, Ok(value))) => {
|
||||
if let Some(entity_id) = resolved_entity.as_deref() {
|
||||
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
Some((resolved_entity, Err(err))) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
let notification_kind = if err.contains("Home Assistant sensor is stale:") {
|
||||
"ha.sensor_stale"
|
||||
} else {
|
||||
"ha.sensor_error"
|
||||
};
|
||||
state.log("warn", notification_kind, &err, json!({
|
||||
"zone_id": zone.id,
|
||||
"configured_entity_id": zone.ha_entity_id.as_deref(),
|
||||
"resolved_entity_id": resolved_entity,
|
||||
}));
|
||||
}
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
|
||||
zone.device_temperature = device_temperature;
|
||||
zone.external_temperature = external_temperature;
|
||||
zone.current_temperature = temperature;
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
// A disabled thermostat zone is completely outside normal controller ownership.
|
||||
// Keep its sensors fresh, but do not let group state, schedules or thermostat
|
||||
// modulation touch the unit. Manual control from the technical Devices view may
|
||||
// therefore remain active until the zone is explicitly enabled again.
|
||||
if !zone.enabled {
|
||||
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
|
||||
}
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A technically disabled device is outside thermostat ownership. Do not create
|
||||
// repeated command errors while keeping any available external sensor data visible.
|
||||
if !device.enabled {
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A physical/manual takeover has higher priority than thermostat, schedule, group and
|
||||
// automation control. Continue sensor/history updates, but reflect the unit's real state
|
||||
// instead of sending corrective frames that would fight the person holding the remote.
|
||||
if zone.device_manual_override {
|
||||
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
|
||||
// selected profile/target. Keep the intended target visible and report the physical
|
||||
// unit target separately through device_setpoint. This makes Resume/Profile actions
|
||||
// deterministic and avoids a standby device target (for example 25 C) masquerading
|
||||
// as the zone's Sleep/Comfort target.
|
||||
let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone());
|
||||
let pause_started_at = zone.updated_at;
|
||||
if temporary_active {
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
|
||||
session.state = "paused_manual".into();
|
||||
session.condition_started_at = None;
|
||||
session.condition_last_observed_at = None;
|
||||
}
|
||||
}
|
||||
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
// Keep effective_mode's existing meaning during takeover: it reflects the physical
|
||||
// unit, while effective_setpoint above remains the thermostat intent.
|
||||
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Temperature completion belongs to the temporary thermostat only while it truly owns
|
||||
// the zone. A manual/device takeover above therefore pauses the hold instead of silently
|
||||
// consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this
|
||||
// control cycle. A long gap resets continuous-hold evidence after restart/stale sensors.
|
||||
let condition_sample_at = match zone.control_temperature_source.as_str() {
|
||||
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
|
||||
_ => device.last_seen.clone(),
|
||||
};
|
||||
let max_condition_gap_seconds = settings.poll_interval_seconds
|
||||
.max(settings.zone_interval_seconds)
|
||||
.saturating_mul(2)
|
||||
.saturating_add(5);
|
||||
let condition_now = zone.updated_at.clone();
|
||||
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
|
||||
&mut zone,
|
||||
condition_now,
|
||||
condition_sample_at,
|
||||
max_condition_gap_seconds,
|
||||
) {
|
||||
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
||||
finish_temporary_quick_thermostat(&mut zone, &schedules, &settings.house_mode);
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await;
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
|
||||
}));
|
||||
state.wake_zone_control();
|
||||
continue;
|
||||
}
|
||||
|
||||
if zone.local_thermostat_power == Some(false) {
|
||||
zone.effective_mode = "off".into();
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
if device.online && device.communication_failures == 0 && device.power {
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
let latest = state.db.get_zone(&zone.id)?;
|
||||
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
|
||||
if let Err(err) = send_command_locked(
|
||||
state,
|
||||
&zone.device_id,
|
||||
DeviceCommand { power: Some(false), ..Default::default() },
|
||||
).await {
|
||||
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
let blocked_by_group = ownership_blocked_by_group;
|
||||
if blocked_by_group {
|
||||
zone.effective_mode = "off".into();
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
if device.online && device.communication_failures == 0 && device.power {
|
||||
match send_zone_command_if_owned(
|
||||
state,
|
||||
&zone.id,
|
||||
&zone.device_id,
|
||||
DeviceCommand { power: Some(false), ..Default::default() },
|
||||
true,
|
||||
).await {
|
||||
Ok(_) => {}
|
||||
Err(err) => state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})),
|
||||
}
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
if discrepancy && previous_source != "device_discrepancy_fallback" {
|
||||
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_temperature": zone.device_temperature,
|
||||
"external_temperature": zone.external_temperature,
|
||||
"max_difference": zone.max_sensor_difference,
|
||||
"entity_id": zone.ha_entity_id.as_deref(),
|
||||
}));
|
||||
}
|
||||
|
||||
// House "off" is a no-control state, not a power-off command. Keep polling and
|
||||
// publishing the zone, but never overwrite manual device state while it follows
|
||||
// the house mode. Explicit per-zone heat/cool bypasses this branch above.
|
||||
if effective_mode == "off" {
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
|
||||
let Some(temp) = temperature else {
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
};
|
||||
|
||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||
let previous_demand = zone.demand;
|
||||
zone.demand = match effective_mode {
|
||||
"heat" => {
|
||||
if temp <= target - half { true }
|
||||
else if temp >= target + half { false }
|
||||
else { zone.demand }
|
||||
}
|
||||
_ => {
|
||||
if temp >= target + half { true }
|
||||
else if temp <= target - half { false }
|
||||
else { zone.demand }
|
||||
}
|
||||
};
|
||||
if zone.demand && !previous_demand {
|
||||
zone.demand_since = Some(Utc::now());
|
||||
zone.target_alerted_at = None;
|
||||
} else if !zone.demand {
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
}
|
||||
if zone.demand && zone.target_alerted_at.is_none() {
|
||||
let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64;
|
||||
if let Some(since) = zone.demand_since {
|
||||
if (Utc::now() - since).num_minutes() >= timeout_minutes {
|
||||
state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({
|
||||
"zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes
|
||||
}));
|
||||
zone.target_alerted_at = Some(Utc::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
||||
// stop naturally when we move the target to the satisfied side of room temperature.
|
||||
let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
|
||||
// When an independent room sensor is actually driving cooling, the indoor unit's
|
||||
// own sensor can satisfy too early. Apply a half-degree pre-rounding bias: because
|
||||
// GREE setpoints are sent as whole degrees, this selects the next lower whole-degree
|
||||
// target (0.5-1.0 C below the room target). Do not stack it with outdoor assist and
|
||||
// do not use it during device/fallback control.
|
||||
let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source);
|
||||
let demand_assist = outdoor_assist.max(room_sensor_assist);
|
||||
let active_target = match effective_mode {
|
||||
"heat" => target + outdoor_assist,
|
||||
_ => target - demand_assist,
|
||||
};
|
||||
let standby_target = match effective_mode {
|
||||
"heat" => target - zone.standby_offset_c.max(0.5),
|
||||
_ => target + zone.standby_offset_c.max(0.5),
|
||||
};
|
||||
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
||||
// Report only the last confirmed device state here. The desired target belongs to
|
||||
// effective_setpoint/command planning until a device command succeeds.
|
||||
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
||||
|
||||
let demand_changed = previous_demand != zone.demand;
|
||||
let desired_fan = if night_active {
|
||||
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||
if zone.smart_fan {
|
||||
Some(night_limited_fan_speed(
|
||||
smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand),
|
||||
max_fan,
|
||||
))
|
||||
} else if device.fan_speed == 0 || device.fan_speed > max_fan {
|
||||
Some(max_fan)
|
||||
} else {
|
||||
Some(device.fan_speed)
|
||||
}
|
||||
} else if zone.smart_fan {
|
||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// When the room becomes satisfied, ask compatible units for Quiet in the same
|
||||
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
|
||||
// on the normal smart-fan transition. When scheduled night mode owns Quiet, it
|
||||
// explicitly enables it inside the window and releases it outside the window.
|
||||
let desired_quiet = smart_quiet_command(
|
||||
zone.smart_fan,
|
||||
state.gree.quiet_command_supported(&device.id),
|
||||
previous_demand,
|
||||
zone.demand,
|
||||
device.quiet,
|
||||
settings.night_mode.enabled,
|
||||
night_active,
|
||||
settings.night_mode.force_quiet,
|
||||
);
|
||||
let desired_sleep = native_sleep_command(
|
||||
settings.night_mode.enabled,
|
||||
night_active,
|
||||
settings.night_mode.use_native_sleep,
|
||||
device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id),
|
||||
device.sleep,
|
||||
);
|
||||
|
||||
// Compressor protection for automatic ownership. Direct/manual commands and global safety OFF
|
||||
// deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap.
|
||||
let now = Utc::now();
|
||||
if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
|
||||
zone.lockout_until = None;
|
||||
zone.lockout_reason = None;
|
||||
}
|
||||
if device.power && device.mode != effective_mode {
|
||||
let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64);
|
||||
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) {
|
||||
let until = zone.last_power_change_at.map(|at| at + min_on);
|
||||
zone.lockout_until = until;
|
||||
zone.lockout_reason = Some("minimum_on_before_mode_change".into());
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await {
|
||||
Ok(Some(_)) => {
|
||||
zone.last_power_change_at = Some(now);
|
||||
zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64));
|
||||
zone.lockout_reason = Some("mode_change_off_delay".into());
|
||||
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until}));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
if !device.power {
|
||||
let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64);
|
||||
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) {
|
||||
zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off);
|
||||
zone.lockout_reason = Some("minimum_off_before_start".into());
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let core_needs_command = !device.power
|
||||
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
|
||||
// In normal standby, Low fan is a transition hint rather than a state that should
|
||||
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
|
||||
// again; retrying every min_adjust_seconds only causes needless command beeps.
|
||||
let fan_needs_command = desired_fan
|
||||
.map(|fan| fan != device.fan_speed)
|
||||
.unwrap_or(false)
|
||||
&& (zone.demand || demand_changed || core_needs_command || night_active);
|
||||
let needs_command = core_needs_command
|
||||
|| fan_needs_command
|
||||
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|
||||
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
|
||||
|
||||
let urgent_start = !device.power;
|
||||
if needs_command && (urgent_start || adjustment_allowed(&zone)) {
|
||||
let command = DeviceCommand {
|
||||
power: Some(true),
|
||||
mode: Some(effective_mode.to_string()),
|
||||
target_temperature: Some(desired_device_target),
|
||||
fan_speed: if fan_needs_command { desired_fan } else { None },
|
||||
quiet: desired_quiet,
|
||||
sleep: desired_sleep,
|
||||
..Default::default()
|
||||
};
|
||||
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await {
|
||||
Ok(Some(updated_device)) => {
|
||||
let transition_at = Utc::now();
|
||||
if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); }
|
||||
if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); }
|
||||
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None };
|
||||
zone.last_action_at = Some(transition_at);
|
||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||
"zone_id": zone.id,
|
||||
"room_temperature": temp,
|
||||
"comfort_target": target,
|
||||
"device_target": desired_device_target,
|
||||
"mode": effective_mode,
|
||||
"preset": zone.active_preset,
|
||||
"outdoor_temperature": outdoor_temperature,
|
||||
"fan_speed": updated_device.fan_speed,
|
||||
"quiet": updated_device.quiet,
|
||||
"sleep": updated_device.sleep,
|
||||
"night_mode": night_active,
|
||||
}));
|
||||
}
|
||||
Ok(None) => {
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
}
|
||||
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+4
-390
@@ -10,394 +10,8 @@ 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)) }
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("influxdb/write.rs");
|
||||
include!("influxdb/query.rs");
|
||||
include!("influxdb/codec.rs");
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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)) }
|
||||
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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(())
|
||||
}
|
||||
|
||||
+10
-1081
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Schedule {
|
||||
pub id: String,
|
||||
pub zone_id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// ISO weekday numbers, Monday=1, Sunday=7.
|
||||
pub weekdays: Vec<u32>,
|
||||
/// Local time HH:MM.
|
||||
pub start_time: String,
|
||||
/// Local time HH:MM. Ranges crossing midnight are supported.
|
||||
pub end_time: String,
|
||||
/// comfort/sleep/away/custom. Non-custom profiles resolve their target from the zone.
|
||||
#[serde(default = "default_schedule_preset")]
|
||||
pub preset: String,
|
||||
pub setpoint: f64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Automation {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// temperature_above, temperature_below, time
|
||||
pub trigger_kind: String,
|
||||
#[serde(default)]
|
||||
pub trigger_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub threshold: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub at_time: Option<String>,
|
||||
/// Legacy/direct-device target. Empty when this automation targets a group.
|
||||
#[serde(default)]
|
||||
pub action_device_id: String,
|
||||
/// Optional climate group target. When set, the action is applied to every member zone/device.
|
||||
#[serde(default)]
|
||||
pub action_group_id: Option<String>,
|
||||
/// Optional thermostat preset used only for group actions.
|
||||
#[serde(default)]
|
||||
pub action_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action: DeviceCommand,
|
||||
#[serde(default = "default_cooldown")]
|
||||
pub cooldown_seconds: u64,
|
||||
#[serde(default)]
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#[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>,
|
||||
#[serde(default)]
|
||||
pub groups: Vec<ClimateGroup>,
|
||||
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,
|
||||
/// Configured per-zone enable switch. Group/master gates are reported separately.
|
||||
pub enabled: bool,
|
||||
/// True when the configured zone is not currently blocked by a disabled climate group.
|
||||
pub effective_enabled: bool,
|
||||
/// Effective mode currently used by the controller.
|
||||
pub mode: String,
|
||||
/// Configured zone mode before house-mode inheritance is resolved.
|
||||
pub configured_mode: String,
|
||||
pub inherit_house_mode: bool,
|
||||
/// Profile resolved from a manual override or the active schedule.
|
||||
pub preset: String,
|
||||
/// Explicit per-zone profile override; None means Auto schedule.
|
||||
pub preset_override: Option<String>,
|
||||
pub current_temperature: Option<f64>,
|
||||
pub target_temperature: Option<f64>,
|
||||
pub device_setpoint: Option<f64>,
|
||||
pub desired_power: bool,
|
||||
pub desired_mode: String,
|
||||
pub actual_power: Option<bool>,
|
||||
pub actual_mode: Option<String>,
|
||||
pub actual_setpoint: Option<f64>,
|
||||
pub demand: bool,
|
||||
pub control_source: String,
|
||||
pub manual_override_until: Option<DateTime<Utc>>,
|
||||
pub local_thermostat_power: Option<bool>,
|
||||
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
|
||||
pub device_manual_override: bool,
|
||||
pub device_manual_override_until: Option<DateTime<Utc>>,
|
||||
pub control_owner: String,
|
||||
pub control_command_source: String,
|
||||
pub control_since: Option<DateTime<Utc>>,
|
||||
pub resume_at: Option<DateTime<Utc>>,
|
||||
pub control_reason: String,
|
||||
pub blocked_reason: Option<String>,
|
||||
pub lockout_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,
|
||||
#[serde(default)]
|
||||
pub action_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_group_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_preset: Option<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,
|
||||
/// Uniform whole-house preset when every zone uses the same override; None for a mixed state.
|
||||
pub house_preset: Option<String>,
|
||||
/// Whole-house master power state.
|
||||
pub house_power: bool,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub control_strategy: String,
|
||||
pub night_mode_active: bool,
|
||||
pub night_mode_start: String,
|
||||
pub night_mode_end: String,
|
||||
pub night_mode_max_fan_speed: u8,
|
||||
pub next_events: Vec<ControlPlanEvent>,
|
||||
pub zones: Vec<ZoneControlPlan>,
|
||||
pub rules: Vec<AutomationPlanRule>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
fn default_true() -> bool { true }
|
||||
fn default_port() -> u16 { 7000 }
|
||||
fn default_protocol() -> u8 { 0 }
|
||||
fn default_mode() -> String { "cool".into() }
|
||||
fn default_fan() -> u8 { 0 }
|
||||
fn default_target() -> f64 { 24.0 }
|
||||
fn default_hysteresis() -> f64 { 0.6 }
|
||||
fn default_external_sensor_weight() -> f64 { 0.4 }
|
||||
fn default_max_sensor_difference() -> f64 { 3.0 }
|
||||
fn default_control_temperature_source() -> String { "device".into() }
|
||||
fn default_min_cycle() -> u64 { 180 }
|
||||
fn default_sensor_stale_after() -> u64 { 300 }
|
||||
fn default_cooldown() -> u64 { 300 }
|
||||
fn default_house_mode() -> String { "cool".into() }
|
||||
fn default_control_strategy() -> String { "setpoint".into() }
|
||||
fn default_standby_offset() -> f64 { 2.0 }
|
||||
fn default_min_adjust() -> u64 { 120 }
|
||||
fn default_schedule_preset() -> String { "custom".into() }
|
||||
fn default_active_preset() -> String { "comfort".into() }
|
||||
fn default_cool_comfort() -> f64 { 23.0 }
|
||||
fn default_cool_sleep() -> f64 { 24.5 }
|
||||
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_event_log_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 }
|
||||
fn default_night_start() -> String { "22:00".into() }
|
||||
fn default_night_end() -> String { "06:00".into() }
|
||||
fn default_night_max_fan_speed() -> u8 { 1 }
|
||||
fn default_group_power_enabled() -> bool { true }
|
||||
fn default_temporary_tolerance() -> f64 { 0.3 }
|
||||
fn default_temporary_start_kind() -> String { "now".into() }
|
||||
fn default_temporary_state() -> String { "scheduled".into() }
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
pub id: String,
|
||||
pub mac: String,
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub protocol_version: u8,
|
||||
#[serde(default)]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub firmware: String,
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cid: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub simulated: bool,
|
||||
#[serde(default)]
|
||||
pub power: bool,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(default = "default_target")]
|
||||
pub target_temperature: f64,
|
||||
#[serde(default = "default_fan")]
|
||||
pub fan_speed: u8,
|
||||
#[serde(default)]
|
||||
pub swing_vertical: bool,
|
||||
#[serde(default)]
|
||||
pub swing_horizontal: bool,
|
||||
#[serde(default)]
|
||||
pub quiet: bool,
|
||||
#[serde(default)]
|
||||
pub turbo: bool,
|
||||
#[serde(default)]
|
||||
pub light: bool,
|
||||
/// Optional GREE feature states. Support is learned from status responses.
|
||||
#[serde(default)]
|
||||
pub air: bool,
|
||||
#[serde(default)]
|
||||
pub xfan: bool,
|
||||
#[serde(default)]
|
||||
pub health: bool,
|
||||
#[serde(default)]
|
||||
pub sleep: bool,
|
||||
#[serde(default)]
|
||||
pub supports_light: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub supports_quiet: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub supports_turbo: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub supports_air: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub supports_xfan: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub supports_health: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub supports_sleep: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub current_temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
/// Some GREE firmware reports TemSen/OutEnvTem with a +40 C wire offset.
|
||||
#[serde(default)]
|
||||
pub temperature_sensor_offset: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub online: bool,
|
||||
/// Round-trip time of the latest successful controller communication.
|
||||
#[serde(default)]
|
||||
pub response_time_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub communication_failures: u8,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn simulated_default() -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: "sim-salon".into(),
|
||||
mac: "SIM000000001".into(),
|
||||
name: "Living Room (simulator)".into(),
|
||||
ip: "127.0.0.1".into(),
|
||||
port: 7000,
|
||||
protocol_version: 1,
|
||||
model: "GREE-SIM".into(),
|
||||
firmware: "sim-1.0".into(),
|
||||
key: None,
|
||||
cid: Some("gree-controller".into()),
|
||||
enabled: true,
|
||||
simulated: true,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 23.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
quiet: false,
|
||||
turbo: false,
|
||||
light: true,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: Some(true),
|
||||
supports_quiet: Some(true),
|
||||
supports_turbo: Some(true),
|
||||
supports_air: Some(true),
|
||||
supports_xfan: Some(true),
|
||||
supports_health: Some(true),
|
||||
supports_sleep: Some(true),
|
||||
current_temperature: Some(26.0),
|
||||
outdoor_temperature: Some(30.0),
|
||||
temperature_sensor_offset: Some(false),
|
||||
online: true,
|
||||
response_time_ms: Some(0),
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DevicePatch {
|
||||
pub name: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub protocol_version: Option<u8>,
|
||||
pub key: Option<Option<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DeviceCommand {
|
||||
pub power: Option<bool>,
|
||||
pub mode: Option<String>,
|
||||
pub target_temperature: Option<f64>,
|
||||
pub fan_speed: Option<u8>,
|
||||
pub swing_vertical: Option<bool>,
|
||||
pub swing_horizontal: Option<bool>,
|
||||
pub quiet: Option<bool>,
|
||||
pub turbo: Option<bool>,
|
||||
pub light: Option<bool>,
|
||||
pub air: Option<bool>,
|
||||
pub xfan: Option<bool>,
|
||||
pub health: Option<bool>,
|
||||
pub sleep: Option<bool>,
|
||||
}
|
||||
|
||||
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()
|
||||
&& self.air.is_none() && self.xfan.is_none() && self.health.is_none() && self.sleep.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),
|
||||
air: self.air.filter(|value| *value != device.air),
|
||||
xfan: self.xfan.filter(|value| *value != device.xfan),
|
||||
health: self.health.filter(|value| *value != device.health),
|
||||
sleep: self.sleep.filter(|value| *value != device.sleep),
|
||||
}
|
||||
}
|
||||
|
||||
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(); }
|
||||
if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0).round(); }
|
||||
if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); }
|
||||
if let Some(v) = self.swing_vertical { device.swing_vertical = v; }
|
||||
if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; }
|
||||
if let Some(v) = self.quiet { device.quiet = v; }
|
||||
if let Some(v) = self.turbo { device.turbo = v; }
|
||||
if let Some(v) = self.light { device.light = v; }
|
||||
if let Some(v) = self.air { device.air = v; }
|
||||
if let Some(v) = self.xfan { device.xfan = v; }
|
||||
if let Some(v) = self.health { device.health = v; }
|
||||
if let Some(v) = self.sleep { device.sleep = v; }
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManualDeviceBaseline {
|
||||
pub power: bool,
|
||||
pub mode: String,
|
||||
pub target_temperature: f64,
|
||||
pub fan_speed: u8,
|
||||
pub quiet: bool,
|
||||
pub sleep: bool,
|
||||
}
|
||||
|
||||
impl From<&Device> for ManualDeviceBaseline {
|
||||
fn from(device: &Device) -> Self {
|
||||
Self {
|
||||
power: device.power,
|
||||
mode: device.mode.clone(),
|
||||
target_temperature: device.target_temperature,
|
||||
fan_speed: device.fan_speed,
|
||||
quiet: device.quiet,
|
||||
sleep: device.sleep,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Reading {
|
||||
pub id: i64,
|
||||
pub device_id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub indoor_temperature: Option<f64>,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub target_temperature: f64,
|
||||
pub power: bool,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ZoneReading {
|
||||
pub id: i64,
|
||||
pub zone_id: String,
|
||||
pub device_id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub gree_temperature: Option<f64>,
|
||||
pub external_temperature: Option<f64>,
|
||||
pub control_temperature: Option<f64>,
|
||||
pub target_temperature: Option<f64>,
|
||||
pub device_setpoint: Option<f64>,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub power: bool,
|
||||
pub mode: String,
|
||||
pub fan_speed: u8,
|
||||
pub demand: bool,
|
||||
pub control_source: String,
|
||||
pub active_preset: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HaReading {
|
||||
pub id: i64,
|
||||
pub entity_id: String,
|
||||
pub zone_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub temperature: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventLog {
|
||||
pub id: i64,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub level: String,
|
||||
pub kind: String,
|
||||
pub message: String,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HomeAssistantSettings {
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub default_entity_id: String,
|
||||
/// Optional outdoor temperature sensor used only as an assist signal.
|
||||
#[serde(default)]
|
||||
pub outdoor_entity_id: String,
|
||||
/// Maximum accepted age of Home Assistant sensor samples.
|
||||
#[serde(default = "default_sensor_stale_after")]
|
||||
pub sensor_stale_after_seconds: u64,
|
||||
/// Accept self-signed/expired certificates for local Home Assistant HTTPS.
|
||||
#[serde(default)]
|
||||
pub allow_invalid_tls: bool,
|
||||
/// Friendly labels used only by the controller UI/charts; entity_id remains the storage key.
|
||||
#[serde(default)]
|
||||
pub sensor_aliases: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct NotificationAlertTypes {
|
||||
/// Home Assistant sensor exceeded the configured freshness window.
|
||||
#[serde(default = "default_true")]
|
||||
pub stale_sensor: bool,
|
||||
/// Other Home Assistant sensor errors (missing/unavailable/invalid value).
|
||||
#[serde(default = "default_true")]
|
||||
pub sensor_errors: bool,
|
||||
/// Device connectivity, polling and communication problems.
|
||||
#[serde(default = "default_true")]
|
||||
pub communication: bool,
|
||||
/// Zone failed to reach its target within the configured timeout.
|
||||
#[serde(default = "default_true")]
|
||||
pub target_timeout: bool,
|
||||
/// Automation execution errors and conflicts.
|
||||
#[serde(default = "default_true")]
|
||||
pub automation: bool,
|
||||
/// Thermostat/group/device control failures and sensor discrepancies.
|
||||
#[serde(default = "default_true")]
|
||||
pub control_errors: bool,
|
||||
/// Informational state changes sent when notification mode is "important".
|
||||
#[serde(default = "default_true")]
|
||||
pub important_events: bool,
|
||||
/// Any warning/error not matched by one of the categories above.
|
||||
#[serde(default = "default_true")]
|
||||
pub other: bool,
|
||||
}
|
||||
|
||||
impl Default for NotificationAlertTypes {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stale_sensor: true, sensor_errors: true, communication: true, target_timeout: true,
|
||||
automation: true, control_errors: true, important_events: true, other: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// problems = warnings/errors/anomalies, important = problems plus important state changes.
|
||||
#[serde(default = "default_notification_mode")]
|
||||
pub mode: String,
|
||||
/// pushover, slack, discord
|
||||
#[serde(default = "default_notification_provider")]
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub pushover_app_token: String,
|
||||
#[serde(default)]
|
||||
pub pushover_user_key: String,
|
||||
#[serde(default)]
|
||||
pub slack_webhook_url: String,
|
||||
#[serde(default)]
|
||||
pub discord_webhook_url: String,
|
||||
#[serde(default = "default_notification_cooldown")]
|
||||
pub cooldown_seconds: u64,
|
||||
#[serde(default = "default_notification_failure_threshold")]
|
||||
pub communication_failure_threshold: u32,
|
||||
#[serde(default = "default_notification_target_timeout")]
|
||||
pub target_timeout_minutes: u32,
|
||||
/// Fine-grained selection of which alert categories may be delivered.
|
||||
#[serde(default)]
|
||||
pub alert_types: NotificationAlertTypes,
|
||||
}
|
||||
|
||||
fn default_notification_mode() -> String { "problems".into() }
|
||||
fn default_notification_provider() -> String { "pushover".into() }
|
||||
fn default_notification_cooldown() -> u64 { 300 }
|
||||
fn default_notification_failure_threshold() -> u32 { 3 }
|
||||
fn default_notification_target_timeout() -> u32 { 60 }
|
||||
|
||||
impl Default for NotificationSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false, mode: default_notification_mode(), provider: default_notification_provider(),
|
||||
pushover_app_token: String::new(), pushover_user_key: String::new(),
|
||||
slack_webhook_url: String::new(), discord_webhook_url: String::new(),
|
||||
cooldown_seconds: default_notification_cooldown(),
|
||||
communication_failure_threshold: default_notification_failure_threshold(),
|
||||
target_timeout_minutes: default_notification_target_timeout(),
|
||||
alert_types: NotificationAlertTypes::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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 NightModeSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_night_start")]
|
||||
pub start_time: String,
|
||||
#[serde(default = "default_night_end")]
|
||||
pub end_time: String,
|
||||
/// Maximum fan speed used by thermostat control during night hours (1=Low..5=High).
|
||||
#[serde(default = "default_night_max_fan_speed")]
|
||||
pub max_fan_speed: u8,
|
||||
/// Ask compatible GREE units to keep Quiet enabled during the whole night window.
|
||||
#[serde(default = "default_true")]
|
||||
pub force_quiet: bool,
|
||||
/// Use the unit's native Sleep function during the night window when it is supported.
|
||||
#[serde(default = "default_true")]
|
||||
pub use_native_sleep: bool,
|
||||
}
|
||||
|
||||
impl Default for NightModeSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
start_time: default_night_start(),
|
||||
end_time: default_night_end(),
|
||||
max_fan_speed: default_night_max_fan_speed(),
|
||||
force_quiet: true,
|
||||
use_native_sleep: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettings {
|
||||
pub controller_id: String,
|
||||
pub simulator_enabled: bool,
|
||||
pub poll_interval_seconds: u64,
|
||||
pub zone_interval_seconds: u64,
|
||||
pub discovery_timeout_ms: u64,
|
||||
pub discovery_broadcast: String,
|
||||
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control.
|
||||
#[serde(default = "default_house_mode")]
|
||||
pub house_mode: String,
|
||||
/// Whole-house master power. False is authoritative and suppresses zone/automation restarts.
|
||||
#[serde(default = "default_true")]
|
||||
pub house_power_enabled: bool,
|
||||
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
|
||||
#[serde(default = "default_control_strategy")]
|
||||
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,
|
||||
/// Retention window for controller event/debug log rows.
|
||||
#[serde(default = "default_event_log_retention_days")]
|
||||
pub event_log_retention_days: u32,
|
||||
/// 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,
|
||||
#[serde(default)]
|
||||
pub night_mode: NightModeSettings,
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationSettings,
|
||||
pub home_assistant: HomeAssistantSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveryRequest {
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub broadcast: Option<String>,
|
||||
/// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only.
|
||||
#[serde(default)]
|
||||
pub protocol_version: Option<u8>,
|
||||
/// Number of scan broadcasts sent during one discovery operation.
|
||||
#[serde(default)]
|
||||
pub passes: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManualDeviceRequest {
|
||||
pub name: String,
|
||||
pub mac: String,
|
||||
pub ip: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub protocol_version: u8,
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub simulated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiTokenInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub token_prefix: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiEvent {
|
||||
pub event: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub data: Value,
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemporaryQuickThermostat {
|
||||
/// now | delay | at. `started_at` is the effective start instant and may be in the future.
|
||||
#[serde(default = "default_temporary_start_kind")]
|
||||
pub start_kind: String,
|
||||
/// duration | until | temperature_reached | temperature_stable | schedule_boundary
|
||||
pub finish_kind: String,
|
||||
/// Effective start instant. Future values mean the temporary thermostat is scheduled
|
||||
/// but does not yet own the zone or block normal schedules/automations.
|
||||
pub started_at: DateTime<Utc>,
|
||||
/// Set when the controller actually activates ownership. Kept separate from started_at
|
||||
/// so a delayed session can survive restarts without being mistaken for an active one.
|
||||
#[serde(default)]
|
||||
pub activated_at: Option<DateTime<Utc>>,
|
||||
/// Explicit lifecycle state: scheduled | waiting_master | paused_manual | active.
|
||||
#[serde(default = "default_temporary_state")]
|
||||
pub state: String,
|
||||
/// Heat/cool mode captured when ownership really starts. It keeps a temporary session
|
||||
/// independent from later whole-house mode changes until hand-back.
|
||||
#[serde(default)]
|
||||
pub active_mode: Option<String>,
|
||||
/// Zone automation enabled-state from immediately before the actual takeover. A delayed
|
||||
/// Quick Thermostat may run even when normal automation was disabled, then restore it.
|
||||
#[serde(default)]
|
||||
pub restore_zone_enabled: Option<bool>,
|
||||
/// Ordinary local Quick Thermostat state hidden underneath this higher-priority session.
|
||||
/// It is captured at actual takeover and restored on hand-back.
|
||||
#[serde(default)]
|
||||
pub restore_local_thermostat_power: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub restore_local_thermostat_resume_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub restore_local_thermostat_zone_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub restore_manual_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub restore_manual_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub restore_manual_override_until: Option<DateTime<Utc>>,
|
||||
/// Hard end for duration/until/schedule-boundary modes.
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
/// Relative durations are retained so delayed/manual-waiting sessions start their clocks
|
||||
/// when ownership actually begins rather than at the originally requested wall-clock time.
|
||||
#[serde(default)]
|
||||
pub duration_seconds: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub safety_duration_seconds: Option<u64>,
|
||||
/// Temperature condition used by reached/stable modes.
|
||||
#[serde(default)]
|
||||
pub temperature_target: Option<f64>,
|
||||
/// within | at_or_below | at_or_above
|
||||
#[serde(default)]
|
||||
pub temperature_operator: Option<String>,
|
||||
#[serde(default = "default_temporary_tolerance")]
|
||||
pub tolerance_c: f64,
|
||||
/// Continuous in-condition time required by temperature_stable.
|
||||
#[serde(default)]
|
||||
pub hold_seconds: u64,
|
||||
/// Set only while fresh consecutive room samples continuously satisfy the condition.
|
||||
#[serde(default)]
|
||||
pub condition_started_at: Option<DateTime<Utc>>,
|
||||
/// Timestamp of the last fresh sensor sample used by the condition evaluator. This
|
||||
/// prevents cached samples and controller downtime from counting as continuous hold time.
|
||||
#[serde(default)]
|
||||
pub condition_last_observed_at: Option<DateTime<Utc>>,
|
||||
/// Start of a higher-priority direct/manual pause. Active deadlines are shifted by this
|
||||
/// pause when ownership returns so hidden manual time is never consumed by the session.
|
||||
#[serde(default)]
|
||||
pub paused_at: Option<DateTime<Utc>>,
|
||||
/// Group/house climate changes received while this session owns the zone. They are
|
||||
/// applied only after hand-back instead of partially overwriting the active session.
|
||||
#[serde(default)]
|
||||
pub deferred_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deferred_preset: Option<String>,
|
||||
/// Optional fail-safe for temperature-based modes.
|
||||
#[serde(default)]
|
||||
pub safety_expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemporaryQuickThermostatRequest {
|
||||
#[serde(default = "default_temporary_start_kind")]
|
||||
pub start_kind: String,
|
||||
#[serde(default)]
|
||||
pub start_delay_minutes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub start_at: Option<DateTime<Utc>>,
|
||||
pub finish_kind: String,
|
||||
#[serde(default)]
|
||||
pub duration_minutes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub target_temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub temperature_operator: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tolerance_c: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub hold_minutes: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub max_duration_minutes: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Zone {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub device_id: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
/// When true, the zone follows the global house heating/cooling mode.
|
||||
#[serde(default = "default_true")]
|
||||
pub inherit_house_mode: bool,
|
||||
/// Manual/custom target retained for quick thermostat overrides.
|
||||
#[serde(default = "default_target")]
|
||||
pub setpoint: f64,
|
||||
/// 0 means a zone created before smart profiles; its legacy setpoint remains the comfort target until edited.
|
||||
#[serde(default)]
|
||||
pub profile_version: u8,
|
||||
#[serde(default = "default_cool_comfort")]
|
||||
pub cool_comfort_setpoint: f64,
|
||||
#[serde(default = "default_cool_sleep")]
|
||||
pub cool_sleep_setpoint: f64,
|
||||
#[serde(default = "default_cool_away")]
|
||||
pub cool_away_setpoint: f64,
|
||||
#[serde(default = "default_heat_comfort")]
|
||||
pub heat_comfort_setpoint: f64,
|
||||
#[serde(default = "default_heat_sleep")]
|
||||
pub heat_sleep_setpoint: f64,
|
||||
#[serde(default = "default_heat_away")]
|
||||
pub heat_away_setpoint: f64,
|
||||
#[serde(default = "default_hysteresis")]
|
||||
pub hysteresis: f64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_on_seconds: u64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_off_seconds: u64,
|
||||
/// Minimum interval between automatic setpoint/fan adjustments.
|
||||
#[serde(default = "default_min_adjust")]
|
||||
pub min_adjust_seconds: u64,
|
||||
/// Difference applied to the AC setpoint while the room is satisfied.
|
||||
#[serde(default = "default_standby_offset")]
|
||||
pub standby_offset_c: f64,
|
||||
/// Let the controller adjust fan speed based on demand and outdoor conditions.
|
||||
#[serde(default = "default_true")]
|
||||
pub smart_fan: bool,
|
||||
#[serde(default = "default_sensor_source")]
|
||||
pub sensor_source: String,
|
||||
#[serde(default)]
|
||||
pub ha_entity_id: Option<String>,
|
||||
/// Weight of the optional room sensor when sensor_source is `combined`.
|
||||
#[serde(default = "default_external_sensor_weight")]
|
||||
pub external_sensor_weight: f64,
|
||||
/// If GREE and external sensor differ more than this, the controller falls back to GREE.
|
||||
#[serde(default = "default_max_sensor_difference")]
|
||||
pub max_sensor_difference: f64,
|
||||
/// Maximum accepted age of a Home Assistant room sensor sample.
|
||||
#[serde(default = "default_sensor_stale_after")]
|
||||
pub sensor_stale_after_seconds: u64,
|
||||
/// Temperature reported by the GREE indoor sensor during the last zone cycle.
|
||||
#[serde(default)]
|
||||
pub device_temperature: Option<f64>,
|
||||
/// Temperature reported by the per-zone external Home Assistant sensor.
|
||||
#[serde(default)]
|
||||
pub external_temperature: Option<f64>,
|
||||
/// Temperature actually used by the zone controller.
|
||||
#[serde(default)]
|
||||
pub current_temperature: Option<f64>,
|
||||
/// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`.
|
||||
#[serde(default = "default_control_temperature_source")]
|
||||
pub control_temperature_source: String,
|
||||
/// Effective profile currently used by the zone: comfort/sleep/away/custom.
|
||||
#[serde(default = "default_active_preset")]
|
||||
pub active_preset: String,
|
||||
/// Optional user preset override. Cleared automatically at the next schedule boundary.
|
||||
#[serde(default)]
|
||||
pub manual_preset: Option<String>,
|
||||
/// Optional quick-thermostat setpoint override. It does not change the active preset.
|
||||
#[serde(default)]
|
||||
pub manual_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub manual_override_until: Option<DateTime<Utc>>,
|
||||
/// Local quick-thermostat power override. None follows group/global power gates;
|
||||
/// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off.
|
||||
#[serde(default)]
|
||||
pub local_thermostat_power: Option<bool>,
|
||||
/// Automatic hand-back deadline after the local quick thermostat is switched OFF.
|
||||
/// When reached, local ownership and quick profile/setpoint overrides are cleared and
|
||||
/// the current group/schedule state is evaluated again.
|
||||
#[serde(default)]
|
||||
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
|
||||
/// Zone automation enabled-state from before an ordinary local Quick Thermostat takeover.
|
||||
/// Kept separate from the temporary-session restore state.
|
||||
#[serde(default)]
|
||||
pub local_thermostat_restore_zone_enabled: Option<bool>,
|
||||
/// Separate, user-defined temporary Quick Thermostat session. This is intentionally
|
||||
/// independent from local_thermostat_resume_at, which belongs to the local-OFF
|
||||
/// hand-back mechanism.
|
||||
#[serde(default)]
|
||||
pub temporary_quick_thermostat: Option<TemporaryQuickThermostat>,
|
||||
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
|
||||
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
|
||||
#[serde(default)]
|
||||
pub device_manual_override: bool,
|
||||
#[serde(default)]
|
||||
pub device_manual_override_since: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub device_manual_override_until: Option<DateTime<Utc>>,
|
||||
/// Climate-relevant fields changed during the current external/manual takeover.
|
||||
#[serde(default)]
|
||||
pub device_manual_override_fields: Vec<String>,
|
||||
/// Controller-observed climate state immediately before the takeover started.
|
||||
/// It lets us drop a stale "resume automation" prompt when the user restores that state.
|
||||
#[serde(default)]
|
||||
pub device_manual_override_baseline: Option<ManualDeviceBaseline>,
|
||||
/// Monotonic configuration/control revision used for optimistic concurrency.
|
||||
#[serde(default)]
|
||||
pub revision: u64,
|
||||
/// Normalized control ownership exposed consistently to API/Web/Home Assistant.
|
||||
#[serde(default)]
|
||||
pub control_owner: String,
|
||||
#[serde(default)]
|
||||
pub control_source: String,
|
||||
#[serde(default)]
|
||||
pub control_since: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub control_resume_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub control_reason: String,
|
||||
/// Last physical power/mode transition timestamps used by compressor lockout.
|
||||
#[serde(default)]
|
||||
pub last_power_change_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_mode_change_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub lockout_until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub lockout_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub effective_mode: String,
|
||||
#[serde(default)]
|
||||
pub effective_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub device_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub demand: bool,
|
||||
#[serde(default)]
|
||||
pub demand_since: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub target_alerted_at: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_action_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_sensor_source() -> String { "device".into() }
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClimateGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub zone_ids: Vec<String>,
|
||||
#[serde(default = "default_group_power_enabled")]
|
||||
pub power_enabled: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct GroupControlPatch {
|
||||
#[serde(default)]
|
||||
pub power: Option<bool>,
|
||||
/// house follows the global house mode; cool/heat set an explicit mode on every member zone.
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
/// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone.
|
||||
#[serde(default)]
|
||||
pub preset: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ZoneControlPatch {
|
||||
#[serde(default)]
|
||||
pub setpoint: Option<f64>,
|
||||
/// Local quick-thermostat power. This is thermostat ownership, not direct device/pilot control.
|
||||
#[serde(default)]
|
||||
pub power: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
/// `auto` clears the override; comfort/sleep/away/custom create a temporary override.
|
||||
#[serde(default)]
|
||||
pub preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clear_override: Option<bool>,
|
||||
/// Explicitly hand control of a manually overridden physical unit back to the thermostat engine.
|
||||
#[serde(default)]
|
||||
pub clear_device_manual_override: Option<bool>,
|
||||
/// Return a locally forced quick thermostat to normal group/schedule ownership.
|
||||
#[serde(default)]
|
||||
pub clear_local_thermostat_override: Option<bool>,
|
||||
/// Start or replace a temporary Quick Thermostat session.
|
||||
#[serde(default)]
|
||||
pub temporary_quick_thermostat: Option<TemporaryQuickThermostatRequest>,
|
||||
/// Stop only the temporary Quick Thermostat session and return to automation.
|
||||
#[serde(default)]
|
||||
pub clear_temporary_quick_thermostat: Option<bool>,
|
||||
}
|
||||
|
||||
+10
-906
@@ -29,910 +29,14 @@ pub struct GreeClient {
|
||||
sleep_unsupported: Arc<Mutex<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl GreeClient {
|
||||
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,
|
||||
received_frames_total: Arc::new(AtomicU64::new(0)),
|
||||
received_frames_by_device: Arc::new(Mutex::new(HashMap::new())),
|
||||
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
sleep_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn received_frame_stats(&self) -> (u64, HashMap<String, u64>) {
|
||||
let total = self.received_frames_total.load(Ordering::Relaxed);
|
||||
let by_device = self.received_frames_by_device.lock()
|
||||
.map(|counts| counts.clone())
|
||||
.unwrap_or_default();
|
||||
(total, by_device)
|
||||
}
|
||||
|
||||
fn record_received_frame(&self, device: &Device) {
|
||||
let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| {
|
||||
let count = counts.entry(device.id.clone()).or_insert(0);
|
||||
*count = (*count).saturating_add(1);
|
||||
*count
|
||||
}).unwrap_or(0);
|
||||
if let Some(events) = &self.debug_events {
|
||||
let _ = events.send(ApiEvent {
|
||||
event: "gree.frame_received".into(),
|
||||
timestamp: Utc::now(),
|
||||
data: json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"total": total,
|
||||
"device_count": device_count,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let socket = if let Some(interface) = self.interface.as_deref() {
|
||||
let ip = interface_ipv4(interface)?;
|
||||
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await
|
||||
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))?
|
||||
} else if let Some(target) = target_hint {
|
||||
if let Some(config) = local_ipv4_config_for_target(target)? {
|
||||
tracing::debug!(
|
||||
target = %target,
|
||||
interface = %config.interface,
|
||||
local_ip = %config.ip,
|
||||
"Automatically selected local interface for GREE UDP"
|
||||
);
|
||||
UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await
|
||||
.with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))?
|
||||
} else {
|
||||
UdpSocket::bind("0.0.0.0:0").await?
|
||||
}
|
||||
} else {
|
||||
UdpSocket::bind("0.0.0.0:0").await?
|
||||
};
|
||||
socket.set_broadcast(broadcast)?;
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
|
||||
let SocketAddr::V4(target_v4) = target else { return Ok(target); };
|
||||
let broadcast = if let Some(interface) = self.interface.as_deref() {
|
||||
let (_, broadcast) = interface_ipv4_config(interface)?;
|
||||
Some(broadcast)
|
||||
} else {
|
||||
local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast)
|
||||
};
|
||||
Ok(broadcast
|
||||
.map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port())))
|
||||
.unwrap_or(target))
|
||||
}
|
||||
|
||||
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
|
||||
let value = configured.trim();
|
||||
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
|
||||
let port = value.split_once(':')
|
||||
.map(|(_, port)| port.parse::<u16>().context("invalid automatic discovery port"))
|
||||
.transpose()?
|
||||
.unwrap_or(7000);
|
||||
let interface = self.interface.as_deref()
|
||||
.ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?;
|
||||
let (_, broadcast) = interface_ipv4_config(interface)?;
|
||||
return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port)));
|
||||
}
|
||||
value.parse().context("invalid discovery broadcast address")
|
||||
}
|
||||
|
||||
/// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only.
|
||||
pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result<Vec<Device>> {
|
||||
let target = self.discovery_target(broadcast)?;
|
||||
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||
let socket = self.udp_socket(true, target_hint).await?;
|
||||
let local = socket.local_addr()?;
|
||||
let passes = passes.clamp(1, 10);
|
||||
tracing::info!(
|
||||
target = %target,
|
||||
local = %local,
|
||||
interface = %self.interface.as_deref().unwrap_or("auto"),
|
||||
protocol = protocol_filter,
|
||||
passes,
|
||||
controller_id = %self.controller_id,
|
||||
"Starting GREE discovery"
|
||||
);
|
||||
|
||||
let deadline = Instant::now() + duration;
|
||||
let interval = if passes > 1 { duration / passes as u32 } else { duration };
|
||||
let mut next_scan = Instant::now();
|
||||
let mut sent = 0_u8;
|
||||
let mut result = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut buffer = vec![0_u8; 16 * 1024];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if sent < passes && Instant::now() >= next_scan {
|
||||
socket.send_to(br#"{"t":"scan"}"#, target).await?;
|
||||
sent += 1;
|
||||
next_scan = Instant::now() + interval.max(Duration::from_millis(250));
|
||||
tracing::debug!(pass = sent, passes, target = %target, "Sent GREE discovery packet");
|
||||
}
|
||||
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let wait = remaining.min(Duration::from_millis(250));
|
||||
match timeout(wait, socket.recv_from(&mut buffer)).await {
|
||||
Ok(Ok((size, source))) => {
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) else { continue; };
|
||||
match self.parse_discovery(value, source) {
|
||||
Ok(Some(mut device)) => {
|
||||
if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; }
|
||||
let key = device.mac.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
device.last_seen = Some(Utc::now());
|
||||
tracing::info!(ip=%device.ip, mac=%device.mac, protocol=device.protocol_version, model=%device.model, firmware=%device.firmware, "Discovered GREE device");
|
||||
result.push(device);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"),
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result<Option<Device>> {
|
||||
let mut detected_protocol = 1_u8;
|
||||
if value.get("t").and_then(Value::as_str) == Some("pack") {
|
||||
if let Some(pack_value) = value.get("pack") {
|
||||
if let Some(pack) = pack_value.as_str() {
|
||||
let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) {
|
||||
detected_protocol = 2;
|
||||
decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)?
|
||||
} else {
|
||||
decrypt_v1(GENERIC_GREE_V1_KEY, pack)?
|
||||
};
|
||||
value = serde_json::from_slice::<Value>(&clear).context("invalid decrypted discovery JSON")?;
|
||||
} else if pack_value.is_object() {
|
||||
value = pack_value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
||||
if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mac = value.get("mac")
|
||||
.or_else(|| value.get("cid"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.replace([':', '-'], "").to_ascii_uppercase();
|
||||
if mac.is_empty() { return Ok(None); }
|
||||
|
||||
let raw_model = value.get("model").or_else(|| value.get("series"))
|
||||
.and_then(Value::as_str).unwrap_or_default().trim().to_string();
|
||||
let model_type = value.get("ModelType")
|
||||
.and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string())))
|
||||
.unwrap_or_default();
|
||||
let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) {
|
||||
format!("GREE {model_type}")
|
||||
} else if raw_model.is_empty() {
|
||||
"GREE".to_string()
|
||||
} else {
|
||||
raw_model
|
||||
};
|
||||
let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim();
|
||||
let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim();
|
||||
let firmware = match (ver.is_empty(), hid.is_empty()) {
|
||||
(false, false) => format!("{ver} · {hid}"),
|
||||
(false, true) => ver.to_string(),
|
||||
(true, false) => hid.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
let suffix = mac.chars().rev().take(4).collect::<String>().chars().rev().collect::<String>().to_ascii_uppercase();
|
||||
let name = value.get("name").and_then(Value::as_str)
|
||||
.map(str::trim).filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("{model} {suffix}"));
|
||||
let now = Utc::now();
|
||||
Ok(Some(Device {
|
||||
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
||||
mac,
|
||||
name,
|
||||
ip: source.ip().to_string(),
|
||||
port: if source.port() == 0 { 7000 } else { source.port() },
|
||||
protocol_version: detected_protocol,
|
||||
model,
|
||||
firmware,
|
||||
key: None,
|
||||
cid: Some("app".into()),
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
quiet: false,
|
||||
turbo: false,
|
||||
light: true,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: None,
|
||||
supports_quiet: None,
|
||||
supports_turbo: None,
|
||||
supports_air: None,
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: true,
|
||||
response_time_ms: None,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn bind(&self, device: &Device) -> Result<BindResult> {
|
||||
let versions: &[u8] = match device.protocol_version {
|
||||
2 => &[2, 1],
|
||||
_ => &[1, 2],
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
for &version in versions {
|
||||
match self.bind_attempt(device, version).await {
|
||||
Ok(key) => return Ok(BindResult { key, protocol_version: version }),
|
||||
Err(err) => {
|
||||
tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed");
|
||||
errors.push(format!("V{version}: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("unable to bind device ({})", errors.join("; "))
|
||||
}
|
||||
|
||||
/// GREE Wi-Fi modules use the 12-hex device id as a protocol identifier.
|
||||
/// Older V1 modules (notably 502cc6...) can silently ignore bind/status
|
||||
/// packets when tcid/mac casing differs from the lowercase value returned
|
||||
/// by discovery. Keep the database/display representation independent from
|
||||
/// the on-wire representation and always send canonical lowercase hex.
|
||||
fn wire_mac(device: &Device) -> String {
|
||||
device.mac.replace([':', '-'], "").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
|
||||
let target = self.device_target(device)?;
|
||||
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||
let socket = self.udp_socket(true, target_hint).await?;
|
||||
|
||||
// Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the
|
||||
// bind window with a subnet broadcast when the target is on a directly
|
||||
// connected network. A unicast scan remains the fallback for routed
|
||||
// deployments. Keep the same UDP socket for scan + bind.
|
||||
let scan_target = self.bind_scan_target(target)?;
|
||||
tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window");
|
||||
socket.send_to(br#"{"t":"scan"}"#, scan_target).await?;
|
||||
let mut scan_buf = vec![0_u8; 16 * 1024];
|
||||
let scan_deadline = Instant::now() + Duration::from_millis(1500);
|
||||
while Instant::now() < scan_deadline {
|
||||
let remaining = scan_deadline.saturating_duration_since(Instant::now());
|
||||
match timeout(remaining, socket.recv_from(&mut scan_buf)).await {
|
||||
Ok(Ok((_size, source))) if source.ip() == target.ip() => {
|
||||
self.record_received_frame(device);
|
||||
tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind");
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let wire_mac = Self::wire_mac(device);
|
||||
let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0});
|
||||
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY };
|
||||
let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?;
|
||||
let kind = response.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
if !kind.eq_ignore_ascii_case("bindok") {
|
||||
bail!("unexpected bind response type: {kind}")
|
||||
}
|
||||
let key = response.get("key").and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("bind response does not contain device key"))?;
|
||||
if key.is_empty() { bail!("device returned an empty key") }
|
||||
Ok(key.to_string())
|
||||
}
|
||||
|
||||
pub async fn poll(&self, device: &mut Device) -> Result<()> {
|
||||
let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let full_cols = [
|
||||
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
|
||||
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
|
||||
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem"
|
||||
];
|
||||
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"];
|
||||
let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await {
|
||||
Ok(value) => (value, false),
|
||||
Err(first) => {
|
||||
tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties");
|
||||
(self.status_request(device, &key, &core_cols).await?, true)
|
||||
}
|
||||
};
|
||||
self.apply_status(device, &response)?;
|
||||
// Some firmware rejects a large mixed property list but still exposes OutEnvTem.
|
||||
// Probe it separately after the core fallback so compatible units can contribute
|
||||
// their outdoor sensor to history without making the main poll fail.
|
||||
if used_core_fallback {
|
||||
match self.status_request(device, &key, &["OutEnvTem"]).await {
|
||||
Ok(optional) => { let _ = self.apply_status(device, &optional); }
|
||||
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
|
||||
}
|
||||
}
|
||||
// Capability discovery is deliberately lazy. Existing installations start with
|
||||
// unknown support flags and each optional property is probed at most until a
|
||||
// definitive success/failure has been persisted with the device state.
|
||||
self.probe_optional_features(device, &key).await;
|
||||
device.online = true;
|
||||
device.communication_failures = 0;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result<Value> {
|
||||
let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"});
|
||||
self.request(device, &inner, key, false, device.protocol_version).await
|
||||
}
|
||||
|
||||
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
|
||||
let probes = [
|
||||
("Lig", device.supports_light.is_none()),
|
||||
("Quiet", device.supports_quiet.is_none()),
|
||||
("Tur", device.supports_turbo.is_none()),
|
||||
("Air", device.supports_air.is_none()),
|
||||
("Blo", device.supports_xfan.is_none()),
|
||||
("Health", device.supports_health.is_none()),
|
||||
("SwhSlp", device.supports_sleep.is_none()),
|
||||
];
|
||||
for (property, needed) in probes {
|
||||
if !needed { continue; }
|
||||
match self.status_request(device, key, &[property]).await {
|
||||
Ok(value) => {
|
||||
let returned = value.get("cols").and_then(Value::as_array)
|
||||
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
|
||||
.unwrap_or(false);
|
||||
if !returned || self.apply_status(device, &value).is_err() {
|
||||
Self::set_feature_support(device, property, false);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
Self::set_feature_support(device, property, false);
|
||||
tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_feature_support(device: &mut Device, property: &str, supported: bool) {
|
||||
let value = Some(supported);
|
||||
match property {
|
||||
"Lig" => device.supports_light = value,
|
||||
"Quiet" => device.supports_quiet = value,
|
||||
"Tur" => device.supports_turbo = value,
|
||||
"Air" => device.supports_air = value,
|
||||
"Blo" => device.supports_xfan = value,
|
||||
"Health" => device.supports_health = value,
|
||||
"SwhSlp" => device.supports_sleep = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
|
||||
let response_cols = response.get("cols").and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("status response has no cols"))?;
|
||||
let data = response.get("dat").and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("status response has no dat"))?;
|
||||
if data.len() < response_cols.len() {
|
||||
bail!("status response contains fewer values than columns")
|
||||
}
|
||||
|
||||
// Parse into a clone and commit only when every climate-relevant value is valid.
|
||||
// This prevents null/text/malformed frames from being silently converted into OFF,
|
||||
// AUTO or a zero setpoint while leaving the rest of the packet partially applied.
|
||||
let mut next = device.clone();
|
||||
let mut set_temp = None;
|
||||
for (name, value) in response_cols.iter().zip(data.iter()) {
|
||||
let Some(name) = name.as_str() else { continue; };
|
||||
match name {
|
||||
"Pow" => next.power = status_flag(name, value)?,
|
||||
"Mod" => {
|
||||
let raw = status_i64(name, value)?;
|
||||
next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into();
|
||||
}
|
||||
"SetTem" => {
|
||||
let raw = status_f64(name, value)?;
|
||||
if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") }
|
||||
set_temp = Some(raw.round());
|
||||
}
|
||||
"WdSpd" => {
|
||||
let raw = status_i64(name, value)?;
|
||||
if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") }
|
||||
next.fan_speed = raw as u8;
|
||||
}
|
||||
"SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0,
|
||||
"SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0,
|
||||
"Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); },
|
||||
"Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); },
|
||||
"Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); },
|
||||
"Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); },
|
||||
"Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); },
|
||||
"Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); },
|
||||
"SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); },
|
||||
"TemSen" => {
|
||||
let raw = status_f64(name, value)?;
|
||||
if raw != 0.0 {
|
||||
let offset = raw > 40.0;
|
||||
let temperature = if offset { raw - 40.0 } else { raw };
|
||||
if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") }
|
||||
next.temperature_sensor_offset = Some(offset);
|
||||
next.current_temperature = Some(temperature);
|
||||
}
|
||||
}
|
||||
"OutEnvTem" => {
|
||||
let raw = status_f64(name, value)?;
|
||||
if raw != 0.0 {
|
||||
let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0);
|
||||
let temperature = if offset { raw - 40.0 } else { raw };
|
||||
if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") }
|
||||
next.outdoor_temperature = Some(temperature);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(base) = set_temp { next.target_temperature = base; }
|
||||
*device = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn quiet_command_supported(&self, device_id: &str) -> bool {
|
||||
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn sleep_command_supported(&self, device_id: &str) -> bool {
|
||||
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn request_command_with_buzzer_fallback(
|
||||
&self,
|
||||
device: &Device,
|
||||
key: &str,
|
||||
command: &DeviceCommand,
|
||||
suppress_beep: bool,
|
||||
) -> Result<Value> {
|
||||
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 buzzer properties instead of ignoring them.
|
||||
// Retry the exact state change without buzzer fields and remember the fallback.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<DeviceCommand> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let mut effective = command.clone();
|
||||
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
|
||||
effective.quiet = None;
|
||||
}
|
||||
if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) {
|
||||
effective.sleep = None;
|
||||
}
|
||||
if effective.is_empty() {
|
||||
return Ok(effective);
|
||||
}
|
||||
|
||||
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
|
||||
Ok(_) => Ok(effective),
|
||||
Err(first_err) => {
|
||||
// Quiet and native Sleep are optional GREE features. A unit may report a
|
||||
// broader status schema than it accepts in command frames, so preserve
|
||||
// the actual thermostat change and retry without the optional property.
|
||||
if effective.sleep.is_some() {
|
||||
let mut fallback = effective.clone();
|
||||
fallback.sleep = None;
|
||||
if !fallback.is_empty() {
|
||||
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
|
||||
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
|
||||
return Ok(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
if effective.quiet.is_some() {
|
||||
let mut fallback = effective.clone();
|
||||
fallback.quiet = None;
|
||||
if !fallback.is_empty() {
|
||||
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
|
||||
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
|
||||
return Ok(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
if effective.sleep.is_some() && effective.quiet.is_some() {
|
||||
let mut fallback = effective.clone();
|
||||
fallback.sleep = None;
|
||||
fallback.quiet = None;
|
||||
if !fallback.is_empty() {
|
||||
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
|
||||
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
|
||||
return Ok(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_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 })); }
|
||||
if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); }
|
||||
if let Some(v) = command.target_temperature {
|
||||
// GREE's Celsius setpoint is whole-degree. TemRec is used by the
|
||||
// Fahrenheit conversion path and should not be abused as a 0.5 C bit.
|
||||
let whole = v.clamp(8.0, 30.0).round() as i64;
|
||||
opt.push("SetTem"); values.push(json!(whole));
|
||||
}
|
||||
if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); }
|
||||
if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
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 let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if opt.is_empty() { bail!("empty device command") }
|
||||
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> {
|
||||
let target = self.device_target(device)?;
|
||||
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||
let socket = self.udp_socket(false, target_hint).await?;
|
||||
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await
|
||||
}
|
||||
|
||||
async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result<Value> {
|
||||
let target = self.device_target(device)?;
|
||||
let version = if protocol_version == 2 { 2 } else { 1 };
|
||||
let inner_bytes = serde_json::to_vec(inner)?;
|
||||
let wire_mac = Self::wire_mac(device);
|
||||
let mut outer = json!({
|
||||
"cid": "app",
|
||||
"i": if binding { 1 } else { 0 },
|
||||
"t": "pack",
|
||||
"tcid": wire_mac,
|
||||
"uid": 0
|
||||
});
|
||||
if version == 2 {
|
||||
let encrypted = encrypt_v2(key, &inner_bytes)?;
|
||||
outer["pack"] = json!(encrypted.ciphertext);
|
||||
outer["tag"] = json!(encrypted.tag);
|
||||
} else {
|
||||
outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?);
|
||||
}
|
||||
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);
|
||||
let mut buffer = vec![0_u8; 16 * 1024];
|
||||
let mut last_decode_error = None;
|
||||
while Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let received = timeout(remaining, socket.recv_from(&mut buffer)).await;
|
||||
let (size, source) = match received {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => break,
|
||||
};
|
||||
if source.ip() != target.ip() { continue; }
|
||||
self.record_received_frame(device);
|
||||
let response: Value = match serde_json::from_slice(&buffer[..size]) {
|
||||
Ok(value) => value,
|
||||
Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; }
|
||||
};
|
||||
if let Some(pack) = response.get("pack").and_then(Value::as_object) {
|
||||
let decoded = Value::Object(pack.clone());
|
||||
if binding {
|
||||
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
if !response_type.eq_ignore_ascii_case("bindok") {
|
||||
tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response");
|
||||
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);
|
||||
}
|
||||
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
|
||||
let clear = if version == 2 {
|
||||
let Some(tag) = response.get("tag").and_then(Value::as_str) else {
|
||||
last_decode_error = Some(anyhow!("AES-GCM response is missing tag"));
|
||||
continue;
|
||||
};
|
||||
match decrypt_v2(key, pack, tag) {
|
||||
Ok(v) => v,
|
||||
Err(err) => { last_decode_error = Some(err); continue; }
|
||||
}
|
||||
} else {
|
||||
match decrypt_v1(key, pack) {
|
||||
Ok(v) => v,
|
||||
Err(err) => { last_decode_error = Some(err); continue; }
|
||||
}
|
||||
};
|
||||
let decoded: Value = match serde_json::from_slice(&clear) {
|
||||
Ok(value) => value,
|
||||
Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; }
|
||||
};
|
||||
if binding {
|
||||
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
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); }
|
||||
bail!("GREE response timeout after 4 seconds")
|
||||
}
|
||||
|
||||
fn device_target(&self, device: &Device) -> Result<SocketAddr> {
|
||||
format!("{}:{}", device.ip, device.port).parse().context("invalid device address")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LocalIpv4Config {
|
||||
interface: String,
|
||||
ip: Ipv4Addr,
|
||||
broadcast: Ipv4Addr,
|
||||
prefix_len: u32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
|
||||
use std::{ffi::CStr, ptr};
|
||||
unsafe {
|
||||
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
|
||||
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
|
||||
let mut current = addrs;
|
||||
let mut best: Option<LocalIpv4Config> = None;
|
||||
while !current.is_null() {
|
||||
let ifa = &*current;
|
||||
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null()
|
||||
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
|
||||
{
|
||||
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
|
||||
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
|
||||
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
|
||||
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
|
||||
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
|
||||
let ip_u32 = u32::from(ip);
|
||||
let mask_u32 = u32::from(mask);
|
||||
let target_u32 = u32::from(target);
|
||||
if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) {
|
||||
let prefix_len = mask_u32.count_ones();
|
||||
let candidate = LocalIpv4Config {
|
||||
interface,
|
||||
ip,
|
||||
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
|
||||
prefix_len,
|
||||
};
|
||||
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
current = ifa.ifa_next;
|
||||
}
|
||||
libc::freeifaddrs(addrs);
|
||||
Ok(best)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) }
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
use std::{ffi::CStr, ptr};
|
||||
unsafe {
|
||||
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
|
||||
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
|
||||
let mut current = addrs;
|
||||
let mut found = None;
|
||||
while !current.is_null() {
|
||||
let ifa = &*current;
|
||||
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() {
|
||||
let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy();
|
||||
if name == interface && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET {
|
||||
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
|
||||
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
|
||||
let broadcast = if !ifa.ifa_netmask.is_null() {
|
||||
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
|
||||
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
|
||||
Ipv4Addr::from(u32::from(ip) | !u32::from(mask))
|
||||
} else { Ipv4Addr::BROADCAST };
|
||||
found = Some((ip, broadcast));
|
||||
break;
|
||||
}
|
||||
}
|
||||
current = ifa.ifa_next;
|
||||
}
|
||||
libc::freeifaddrs(addrs);
|
||||
found.ok_or_else(|| anyhow!("interface {interface} has no IPv4 address"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
bail!("GREE interface binding is only supported on Linux (requested {interface})")
|
||||
}
|
||||
|
||||
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> { interface_ipv4_config(interface).map(|(ip, _)| ip) }
|
||||
fn value_as_i64(value: &Value) -> Option<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) }
|
||||
fn value_as_f64(value: &Value) -> Option<f64> {
|
||||
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite())
|
||||
}
|
||||
fn status_i64(name: &str, value: &Value) -> Result<i64> {
|
||||
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
|
||||
}
|
||||
fn status_f64(name: &str, value: &Value) -> Result<f64> {
|
||||
value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}"))
|
||||
}
|
||||
fn status_flag(name: &str, value: &Value) -> Result<bool> {
|
||||
match status_i64(name, value)? {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
other => bail!("invalid GREE flag value for {name}: {other}"),
|
||||
}
|
||||
}
|
||||
fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } }
|
||||
fn mode_value(value: &str) -> Result<i64> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
|
||||
_ => bail!("unsupported mode: {value}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device {
|
||||
if let Some(mut old) = existing {
|
||||
old.ip = discovered.ip;
|
||||
old.port = discovered.port;
|
||||
if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; }
|
||||
if !discovered.model.is_empty() { old.model = discovered.model; }
|
||||
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; }
|
||||
if old.protocol_version != discovered.protocol_version {
|
||||
old.protocol_version = discovered.protocol_version;
|
||||
old.key = None;
|
||||
}
|
||||
old.online = true;
|
||||
old.communication_failures = 0;
|
||||
old.last_seen = Some(Utc::now());
|
||||
old.last_error = None;
|
||||
old.updated_at = Utc::now();
|
||||
old
|
||||
} else {
|
||||
let mut new = discovered;
|
||||
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); }
|
||||
new
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_status_frame_does_not_partially_mutate_device() {
|
||||
let client = GreeClient::new(
|
||||
"test-controller".into(),
|
||||
None,
|
||||
None,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
);
|
||||
let mut device = Device::simulated_default();
|
||||
device.power = true;
|
||||
device.mode = "heat".into();
|
||||
device.target_temperature = 24.0;
|
||||
let before = device.clone();
|
||||
|
||||
let response = json!({
|
||||
"cols": ["Pow", "Mod", "SetTem"],
|
||||
"dat": [0, null, "not-a-number"]
|
||||
});
|
||||
assert!(client.apply_status(&mut device, &response).is_err());
|
||||
assert_eq!(device.power, before.power);
|
||||
assert_eq!(device.mode, before.mode);
|
||||
assert_eq!(device.target_temperature, before.target_temperature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
|
||||
let payload = GreeClient::command_payload(&DeviceCommand {
|
||||
target_temperature: Some(19.0),
|
||||
fan_speed: Some(1),
|
||||
quiet: Some(true),
|
||||
sleep: Some(true),
|
||||
..DeviceCommand::default()
|
||||
}, false).expect("thermostat command payload");
|
||||
|
||||
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])));
|
||||
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1])));
|
||||
}
|
||||
}
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("gree/core.rs");
|
||||
include!("gree/discovery.rs");
|
||||
include!("gree/binding.rs");
|
||||
include!("gree/polling.rs");
|
||||
include!("gree/commands.rs");
|
||||
include!("gree/transport.rs");
|
||||
include!("gree/network.rs");
|
||||
include!("gree/merge.rs");
|
||||
include!("gree/tests.rs");
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
impl GreeClient {
|
||||
pub async fn bind(&self, device: &Device) -> Result<BindResult> {
|
||||
let versions: &[u8] = match device.protocol_version {
|
||||
2 => &[2, 1],
|
||||
_ => &[1, 2],
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
for &version in versions {
|
||||
match self.bind_attempt(device, version).await {
|
||||
Ok(key) => return Ok(BindResult { key, protocol_version: version }),
|
||||
Err(err) => {
|
||||
tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed");
|
||||
errors.push(format!("V{version}: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
bail!("unable to bind device ({})", errors.join("; "))
|
||||
}
|
||||
|
||||
/// GREE Wi-Fi modules use the 12-hex device id as a protocol identifier.
|
||||
/// Older V1 modules (notably 502cc6...) can silently ignore bind/status
|
||||
/// packets when tcid/mac casing differs from the lowercase value returned
|
||||
/// by discovery. Keep the database/display representation independent from
|
||||
/// the on-wire representation and always send canonical lowercase hex.
|
||||
fn wire_mac(device: &Device) -> String {
|
||||
device.mac.replace([':', '-'], "").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
|
||||
let target = self.device_target(device)?;
|
||||
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||
let socket = self.udp_socket(true, target_hint).await?;
|
||||
|
||||
// Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the
|
||||
// bind window with a subnet broadcast when the target is on a directly
|
||||
// connected network. A unicast scan remains the fallback for routed
|
||||
// deployments. Keep the same UDP socket for scan + bind.
|
||||
let scan_target = self.bind_scan_target(target)?;
|
||||
tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window");
|
||||
socket.send_to(br#"{"t":"scan"}"#, scan_target).await?;
|
||||
let mut scan_buf = vec![0_u8; 16 * 1024];
|
||||
let scan_deadline = Instant::now() + Duration::from_millis(1500);
|
||||
while Instant::now() < scan_deadline {
|
||||
let remaining = scan_deadline.saturating_duration_since(Instant::now());
|
||||
match timeout(remaining, socket.recv_from(&mut scan_buf)).await {
|
||||
Ok(Ok((_size, source))) if source.ip() == target.ip() => {
|
||||
self.record_received_frame(device);
|
||||
tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind");
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
let wire_mac = Self::wire_mac(device);
|
||||
let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0});
|
||||
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY };
|
||||
let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?;
|
||||
let kind = response.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
if !kind.eq_ignore_ascii_case("bindok") {
|
||||
bail!("unexpected bind response type: {kind}")
|
||||
}
|
||||
let key = response.get("key").and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("bind response does not contain device key"))?;
|
||||
if key.is_empty() { bail!("device returned an empty key") }
|
||||
Ok(key.to_string())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
impl GreeClient {
|
||||
pub fn quiet_command_supported(&self, device_id: &str) -> bool {
|
||||
self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn sleep_command_supported(&self, device_id: &str) -> bool {
|
||||
self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn request_command_with_buzzer_fallback(
|
||||
&self,
|
||||
device: &Device,
|
||||
key: &str,
|
||||
command: &DeviceCommand,
|
||||
suppress_beep: bool,
|
||||
) -> Result<Value> {
|
||||
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 buzzer properties instead of ignoring them.
|
||||
// Retry the exact state change without buzzer fields and remember the fallback.
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result<DeviceCommand> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let mut effective = command.clone();
|
||||
if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) {
|
||||
effective.quiet = None;
|
||||
}
|
||||
if effective.sleep.is_some() && !self.sleep_command_supported(&device.id) {
|
||||
effective.sleep = None;
|
||||
}
|
||||
if effective.is_empty() {
|
||||
return Ok(effective);
|
||||
}
|
||||
|
||||
match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await {
|
||||
Ok(_) => Ok(effective),
|
||||
Err(first_err) => {
|
||||
// Quiet and native Sleep are optional GREE features. A unit may report a
|
||||
// broader status schema than it accepts in command frames, so preserve
|
||||
// the actual thermostat change and retry without the optional property.
|
||||
if effective.sleep.is_some() {
|
||||
let mut fallback = effective.clone();
|
||||
fallback.sleep = None;
|
||||
if !fallback.is_empty() {
|
||||
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
|
||||
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device");
|
||||
return Ok(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
if effective.quiet.is_some() {
|
||||
let mut fallback = effective.clone();
|
||||
fallback.quiet = None;
|
||||
if !fallback.is_empty() {
|
||||
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
|
||||
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device");
|
||||
return Ok(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
if effective.sleep.is_some() && effective.quiet.is_some() {
|
||||
let mut fallback = effective.clone();
|
||||
fallback.sleep = None;
|
||||
fallback.quiet = None;
|
||||
if !fallback.is_empty() {
|
||||
if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() {
|
||||
if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); }
|
||||
tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command");
|
||||
return Ok(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_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 })); }
|
||||
if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); }
|
||||
if let Some(v) = command.target_temperature {
|
||||
// GREE's Celsius setpoint is whole-degree. TemRec is used by the
|
||||
// Fahrenheit conversion path and should not be abused as a 0.5 C bit.
|
||||
let whole = v.clamp(8.0, 30.0).round() as i64;
|
||||
opt.push("SetTem"); values.push(json!(whole));
|
||||
}
|
||||
if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); }
|
||||
if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
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 let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if opt.is_empty() { bail!("empty device command") }
|
||||
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"}))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
impl GreeClient {
|
||||
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,
|
||||
received_frames_total: Arc::new(AtomicU64::new(0)),
|
||||
received_frames_by_device: Arc::new(Mutex::new(HashMap::new())),
|
||||
buzzer_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
quiet_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
sleep_unsupported: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn received_frame_stats(&self) -> (u64, HashMap<String, u64>) {
|
||||
let total = self.received_frames_total.load(Ordering::Relaxed);
|
||||
let by_device = self.received_frames_by_device.lock()
|
||||
.map(|counts| counts.clone())
|
||||
.unwrap_or_default();
|
||||
(total, by_device)
|
||||
}
|
||||
|
||||
fn record_received_frame(&self, device: &Device) {
|
||||
let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| {
|
||||
let count = counts.entry(device.id.clone()).or_insert(0);
|
||||
*count = (*count).saturating_add(1);
|
||||
*count
|
||||
}).unwrap_or(0);
|
||||
if let Some(events) = &self.debug_events {
|
||||
let _ = events.send(ApiEvent {
|
||||
event: "gree.frame_received".into(),
|
||||
timestamp: Utc::now(),
|
||||
data: json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"total": total,
|
||||
"device_count": device_count,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
let socket = if let Some(interface) = self.interface.as_deref() {
|
||||
let ip = interface_ipv4(interface)?;
|
||||
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await
|
||||
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))?
|
||||
} else if let Some(target) = target_hint {
|
||||
if let Some(config) = local_ipv4_config_for_target(target)? {
|
||||
tracing::debug!(
|
||||
target = %target,
|
||||
interface = %config.interface,
|
||||
local_ip = %config.ip,
|
||||
"Automatically selected local interface for GREE UDP"
|
||||
);
|
||||
UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await
|
||||
.with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))?
|
||||
} else {
|
||||
UdpSocket::bind("0.0.0.0:0").await?
|
||||
}
|
||||
} else {
|
||||
UdpSocket::bind("0.0.0.0:0").await?
|
||||
};
|
||||
socket.set_broadcast(broadcast)?;
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
|
||||
let SocketAddr::V4(target_v4) = target else { return Ok(target); };
|
||||
let broadcast = if let Some(interface) = self.interface.as_deref() {
|
||||
let (_, broadcast) = interface_ipv4_config(interface)?;
|
||||
Some(broadcast)
|
||||
} else {
|
||||
local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast)
|
||||
};
|
||||
Ok(broadcast
|
||||
.map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port())))
|
||||
.unwrap_or(target))
|
||||
}
|
||||
|
||||
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
|
||||
let value = configured.trim();
|
||||
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
|
||||
let port = value.split_once(':')
|
||||
.map(|(_, port)| port.parse::<u16>().context("invalid automatic discovery port"))
|
||||
.transpose()?
|
||||
.unwrap_or(7000);
|
||||
let interface = self.interface.as_deref()
|
||||
.ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?;
|
||||
let (_, broadcast) = interface_ipv4_config(interface)?;
|
||||
return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port)));
|
||||
}
|
||||
value.parse().context("invalid discovery broadcast address")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
impl GreeClient {
|
||||
/// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only.
|
||||
pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result<Vec<Device>> {
|
||||
let target = self.discovery_target(broadcast)?;
|
||||
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||
let socket = self.udp_socket(true, target_hint).await?;
|
||||
let local = socket.local_addr()?;
|
||||
let passes = passes.clamp(1, 10);
|
||||
tracing::info!(
|
||||
target = %target,
|
||||
local = %local,
|
||||
interface = %self.interface.as_deref().unwrap_or("auto"),
|
||||
protocol = protocol_filter,
|
||||
passes,
|
||||
controller_id = %self.controller_id,
|
||||
"Starting GREE discovery"
|
||||
);
|
||||
|
||||
let deadline = Instant::now() + duration;
|
||||
let interval = if passes > 1 { duration / passes as u32 } else { duration };
|
||||
let mut next_scan = Instant::now();
|
||||
let mut sent = 0_u8;
|
||||
let mut result = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut buffer = vec![0_u8; 16 * 1024];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
if sent < passes && Instant::now() >= next_scan {
|
||||
socket.send_to(br#"{"t":"scan"}"#, target).await?;
|
||||
sent += 1;
|
||||
next_scan = Instant::now() + interval.max(Duration::from_millis(250));
|
||||
tracing::debug!(pass = sent, passes, target = %target, "Sent GREE discovery packet");
|
||||
}
|
||||
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let wait = remaining.min(Duration::from_millis(250));
|
||||
match timeout(wait, socket.recv_from(&mut buffer)).await {
|
||||
Ok(Ok((size, source))) => {
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) else { continue; };
|
||||
match self.parse_discovery(value, source) {
|
||||
Ok(Some(mut device)) => {
|
||||
if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; }
|
||||
let key = device.mac.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
device.last_seen = Some(Utc::now());
|
||||
tracing::info!(ip=%device.ip, mac=%device.mac, protocol=device.protocol_version, model=%device.model, firmware=%device.firmware, "Discovered GREE device");
|
||||
result.push(device);
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"),
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result<Option<Device>> {
|
||||
let mut detected_protocol = 1_u8;
|
||||
if value.get("t").and_then(Value::as_str) == Some("pack") {
|
||||
if let Some(pack_value) = value.get("pack") {
|
||||
if let Some(pack) = pack_value.as_str() {
|
||||
let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) {
|
||||
detected_protocol = 2;
|
||||
decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)?
|
||||
} else {
|
||||
decrypt_v1(GENERIC_GREE_V1_KEY, pack)?
|
||||
};
|
||||
value = serde_json::from_slice::<Value>(&clear).context("invalid decrypted discovery JSON")?;
|
||||
} else if pack_value.is_object() {
|
||||
value = pack_value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase();
|
||||
if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mac = value.get("mac")
|
||||
.or_else(|| value.get("cid"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.replace([':', '-'], "").to_ascii_uppercase();
|
||||
if mac.is_empty() { return Ok(None); }
|
||||
|
||||
let raw_model = value.get("model").or_else(|| value.get("series"))
|
||||
.and_then(Value::as_str).unwrap_or_default().trim().to_string();
|
||||
let model_type = value.get("ModelType")
|
||||
.and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string())))
|
||||
.unwrap_or_default();
|
||||
let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) {
|
||||
format!("GREE {model_type}")
|
||||
} else if raw_model.is_empty() {
|
||||
"GREE".to_string()
|
||||
} else {
|
||||
raw_model
|
||||
};
|
||||
let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim();
|
||||
let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim();
|
||||
let firmware = match (ver.is_empty(), hid.is_empty()) {
|
||||
(false, false) => format!("{ver} · {hid}"),
|
||||
(false, true) => ver.to_string(),
|
||||
(true, false) => hid.to_string(),
|
||||
(true, true) => String::new(),
|
||||
};
|
||||
let suffix = mac.chars().rev().take(4).collect::<String>().chars().rev().collect::<String>().to_ascii_uppercase();
|
||||
let name = value.get("name").and_then(Value::as_str)
|
||||
.map(str::trim).filter(|v| !v.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("{model} {suffix}"));
|
||||
let now = Utc::now();
|
||||
Ok(Some(Device {
|
||||
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
||||
mac,
|
||||
name,
|
||||
ip: source.ip().to_string(),
|
||||
port: if source.port() == 0 { 7000 } else { source.port() },
|
||||
protocol_version: detected_protocol,
|
||||
model,
|
||||
firmware,
|
||||
key: None,
|
||||
cid: Some("app".into()),
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
quiet: false,
|
||||
turbo: false,
|
||||
light: true,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: None,
|
||||
supports_quiet: None,
|
||||
supports_turbo: None,
|
||||
supports_air: None,
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: true,
|
||||
response_time_ms: None,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device {
|
||||
if let Some(mut old) = existing {
|
||||
old.ip = discovered.ip;
|
||||
old.port = discovered.port;
|
||||
if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; }
|
||||
if !discovered.model.is_empty() { old.model = discovered.model; }
|
||||
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; }
|
||||
if old.protocol_version != discovered.protocol_version {
|
||||
old.protocol_version = discovered.protocol_version;
|
||||
old.key = None;
|
||||
}
|
||||
old.online = true;
|
||||
old.communication_failures = 0;
|
||||
old.last_seen = Some(Utc::now());
|
||||
old.last_error = None;
|
||||
old.updated_at = Utc::now();
|
||||
old
|
||||
} else {
|
||||
let mut new = discovered;
|
||||
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); }
|
||||
new
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#[derive(Debug, Clone)]
|
||||
struct LocalIpv4Config {
|
||||
interface: String,
|
||||
ip: Ipv4Addr,
|
||||
broadcast: Ipv4Addr,
|
||||
prefix_len: u32,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
|
||||
use std::{ffi::CStr, ptr};
|
||||
unsafe {
|
||||
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
|
||||
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
|
||||
let mut current = addrs;
|
||||
let mut best: Option<LocalIpv4Config> = None;
|
||||
while !current.is_null() {
|
||||
let ifa = &*current;
|
||||
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null()
|
||||
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
|
||||
{
|
||||
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
|
||||
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
|
||||
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
|
||||
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
|
||||
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
|
||||
let ip_u32 = u32::from(ip);
|
||||
let mask_u32 = u32::from(mask);
|
||||
let target_u32 = u32::from(target);
|
||||
if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) {
|
||||
let prefix_len = mask_u32.count_ones();
|
||||
let candidate = LocalIpv4Config {
|
||||
interface,
|
||||
ip,
|
||||
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
|
||||
prefix_len,
|
||||
};
|
||||
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
current = ifa.ifa_next;
|
||||
}
|
||||
libc::freeifaddrs(addrs);
|
||||
Ok(best)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) }
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
use std::{ffi::CStr, ptr};
|
||||
unsafe {
|
||||
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
|
||||
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
|
||||
let mut current = addrs;
|
||||
let mut found = None;
|
||||
while !current.is_null() {
|
||||
let ifa = &*current;
|
||||
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() {
|
||||
let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy();
|
||||
if name == interface && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET {
|
||||
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
|
||||
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
|
||||
let broadcast = if !ifa.ifa_netmask.is_null() {
|
||||
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
|
||||
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
|
||||
Ipv4Addr::from(u32::from(ip) | !u32::from(mask))
|
||||
} else { Ipv4Addr::BROADCAST };
|
||||
found = Some((ip, broadcast));
|
||||
break;
|
||||
}
|
||||
}
|
||||
current = ifa.ifa_next;
|
||||
}
|
||||
libc::freeifaddrs(addrs);
|
||||
found.ok_or_else(|| anyhow!("interface {interface} has no IPv4 address"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||
bail!("GREE interface binding is only supported on Linux (requested {interface})")
|
||||
}
|
||||
|
||||
fn interface_ipv4(interface: &str) -> Result<Ipv4Addr> { interface_ipv4_config(interface).map(|(ip, _)| ip) }
|
||||
fn value_as_i64(value: &Value) -> Option<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) }
|
||||
fn value_as_f64(value: &Value) -> Option<f64> {
|
||||
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite())
|
||||
}
|
||||
fn status_i64(name: &str, value: &Value) -> Result<i64> {
|
||||
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
|
||||
}
|
||||
fn status_f64(name: &str, value: &Value) -> Result<f64> {
|
||||
value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}"))
|
||||
}
|
||||
fn status_flag(name: &str, value: &Value) -> Result<bool> {
|
||||
match status_i64(name, value)? {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
other => bail!("invalid GREE flag value for {name}: {other}"),
|
||||
}
|
||||
}
|
||||
fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } }
|
||||
fn mode_value(value: &str) -> Result<i64> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
|
||||
_ => bail!("unsupported mode: {value}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
impl GreeClient {
|
||||
pub async fn poll(&self, device: &mut Device) -> Result<()> {
|
||||
let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let full_cols = [
|
||||
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
|
||||
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
|
||||
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem"
|
||||
];
|
||||
let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"];
|
||||
let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await {
|
||||
Ok(value) => (value, false),
|
||||
Err(first) => {
|
||||
tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties");
|
||||
(self.status_request(device, &key, &core_cols).await?, true)
|
||||
}
|
||||
};
|
||||
self.apply_status(device, &response)?;
|
||||
// Some firmware rejects a large mixed property list but still exposes OutEnvTem.
|
||||
// Probe it separately after the core fallback so compatible units can contribute
|
||||
// their outdoor sensor to history without making the main poll fail.
|
||||
if used_core_fallback {
|
||||
match self.status_request(device, &key, &["OutEnvTem"]).await {
|
||||
Ok(optional) => { let _ = self.apply_status(device, &optional); }
|
||||
Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"),
|
||||
}
|
||||
}
|
||||
// Capability discovery is deliberately lazy. Existing installations start with
|
||||
// unknown support flags and each optional property is probed at most until a
|
||||
// definitive success/failure has been persisted with the device state.
|
||||
self.probe_optional_features(device, &key).await;
|
||||
device.online = true;
|
||||
device.communication_failures = 0;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result<Value> {
|
||||
let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"});
|
||||
self.request(device, &inner, key, false, device.protocol_version).await
|
||||
}
|
||||
|
||||
async fn probe_optional_features(&self, device: &mut Device, key: &str) {
|
||||
let probes = [
|
||||
("Lig", device.supports_light.is_none()),
|
||||
("Quiet", device.supports_quiet.is_none()),
|
||||
("Tur", device.supports_turbo.is_none()),
|
||||
("Air", device.supports_air.is_none()),
|
||||
("Blo", device.supports_xfan.is_none()),
|
||||
("Health", device.supports_health.is_none()),
|
||||
("SwhSlp", device.supports_sleep.is_none()),
|
||||
];
|
||||
for (property, needed) in probes {
|
||||
if !needed { continue; }
|
||||
match self.status_request(device, key, &[property]).await {
|
||||
Ok(value) => {
|
||||
let returned = value.get("cols").and_then(Value::as_array)
|
||||
.map(|cols| cols.iter().any(|name| name.as_str() == Some(property)))
|
||||
.unwrap_or(false);
|
||||
if !returned || self.apply_status(device, &value).is_err() {
|
||||
Self::set_feature_support(device, property, false);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
Self::set_feature_support(device, property, false);
|
||||
tracing::trace!(device=%device.id, property, error=?err, "optional GREE feature is not available");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_feature_support(device: &mut Device, property: &str, supported: bool) {
|
||||
let value = Some(supported);
|
||||
match property {
|
||||
"Lig" => device.supports_light = value,
|
||||
"Quiet" => device.supports_quiet = value,
|
||||
"Tur" => device.supports_turbo = value,
|
||||
"Air" => device.supports_air = value,
|
||||
"Blo" => device.supports_xfan = value,
|
||||
"Health" => device.supports_health = value,
|
||||
"SwhSlp" => device.supports_sleep = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> {
|
||||
let response_cols = response.get("cols").and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("status response has no cols"))?;
|
||||
let data = response.get("dat").and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("status response has no dat"))?;
|
||||
if data.len() < response_cols.len() {
|
||||
bail!("status response contains fewer values than columns")
|
||||
}
|
||||
|
||||
// Parse into a clone and commit only when every climate-relevant value is valid.
|
||||
// This prevents null/text/malformed frames from being silently converted into OFF,
|
||||
// AUTO or a zero setpoint while leaving the rest of the packet partially applied.
|
||||
let mut next = device.clone();
|
||||
let mut set_temp = None;
|
||||
for (name, value) in response_cols.iter().zip(data.iter()) {
|
||||
let Some(name) = name.as_str() else { continue; };
|
||||
match name {
|
||||
"Pow" => next.power = status_flag(name, value)?,
|
||||
"Mod" => {
|
||||
let raw = status_i64(name, value)?;
|
||||
next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into();
|
||||
}
|
||||
"SetTem" => {
|
||||
let raw = status_f64(name, value)?;
|
||||
if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") }
|
||||
set_temp = Some(raw.round());
|
||||
}
|
||||
"WdSpd" => {
|
||||
let raw = status_i64(name, value)?;
|
||||
if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") }
|
||||
next.fan_speed = raw as u8;
|
||||
}
|
||||
"SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0,
|
||||
"SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0,
|
||||
"Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); },
|
||||
"Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); },
|
||||
"Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); },
|
||||
"Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); },
|
||||
"Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); },
|
||||
"Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); },
|
||||
"SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); },
|
||||
"TemSen" => {
|
||||
let raw = status_f64(name, value)?;
|
||||
if raw != 0.0 {
|
||||
let offset = raw > 40.0;
|
||||
let temperature = if offset { raw - 40.0 } else { raw };
|
||||
if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") }
|
||||
next.temperature_sensor_offset = Some(offset);
|
||||
next.current_temperature = Some(temperature);
|
||||
}
|
||||
}
|
||||
"OutEnvTem" => {
|
||||
let raw = status_f64(name, value)?;
|
||||
if raw != 0.0 {
|
||||
let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0);
|
||||
let temperature = if offset { raw - 40.0 } else { raw };
|
||||
if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") }
|
||||
next.outdoor_temperature = Some(temperature);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(base) = set_temp { next.target_temperature = base; }
|
||||
*device = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn invalid_status_frame_does_not_partially_mutate_device() {
|
||||
let client = GreeClient::new(
|
||||
"test-controller".into(),
|
||||
None,
|
||||
None,
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
);
|
||||
let mut device = Device::simulated_default();
|
||||
device.power = true;
|
||||
device.mode = "heat".into();
|
||||
device.target_temperature = 24.0;
|
||||
let before = device.clone();
|
||||
|
||||
let response = json!({
|
||||
"cols": ["Pow", "Mod", "SetTem"],
|
||||
"dat": [0, null, "not-a-number"]
|
||||
});
|
||||
assert!(client.apply_status(&mut device, &response).is_err());
|
||||
assert_eq!(device.power, before.power);
|
||||
assert_eq!(device.mode, before.mode);
|
||||
assert_eq!(device.target_temperature, before.target_temperature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() {
|
||||
let payload = GreeClient::command_payload(&DeviceCommand {
|
||||
target_temperature: Some(19.0),
|
||||
fan_speed: Some(1),
|
||||
quiet: Some(true),
|
||||
sleep: Some(true),
|
||||
..DeviceCommand::default()
|
||||
}, false).expect("thermostat command payload");
|
||||
|
||||
assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])));
|
||||
assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
impl GreeClient {
|
||||
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
|
||||
let target = self.device_target(device)?;
|
||||
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||
let socket = self.udp_socket(false, target_hint).await?;
|
||||
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await
|
||||
}
|
||||
|
||||
async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result<Value> {
|
||||
let target = self.device_target(device)?;
|
||||
let version = if protocol_version == 2 { 2 } else { 1 };
|
||||
let inner_bytes = serde_json::to_vec(inner)?;
|
||||
let wire_mac = Self::wire_mac(device);
|
||||
let mut outer = json!({
|
||||
"cid": "app",
|
||||
"i": if binding { 1 } else { 0 },
|
||||
"t": "pack",
|
||||
"tcid": wire_mac,
|
||||
"uid": 0
|
||||
});
|
||||
if version == 2 {
|
||||
let encrypted = encrypt_v2(key, &inner_bytes)?;
|
||||
outer["pack"] = json!(encrypted.ciphertext);
|
||||
outer["tag"] = json!(encrypted.tag);
|
||||
} else {
|
||||
outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?);
|
||||
}
|
||||
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);
|
||||
let mut buffer = vec![0_u8; 16 * 1024];
|
||||
let mut last_decode_error = None;
|
||||
while Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
let received = timeout(remaining, socket.recv_from(&mut buffer)).await;
|
||||
let (size, source) = match received {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => break,
|
||||
};
|
||||
if source.ip() != target.ip() { continue; }
|
||||
self.record_received_frame(device);
|
||||
let response: Value = match serde_json::from_slice(&buffer[..size]) {
|
||||
Ok(value) => value,
|
||||
Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; }
|
||||
};
|
||||
if let Some(pack) = response.get("pack").and_then(Value::as_object) {
|
||||
let decoded = Value::Object(pack.clone());
|
||||
if binding {
|
||||
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
if !response_type.eq_ignore_ascii_case("bindok") {
|
||||
tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response");
|
||||
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);
|
||||
}
|
||||
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
|
||||
let clear = if version == 2 {
|
||||
let Some(tag) = response.get("tag").and_then(Value::as_str) else {
|
||||
last_decode_error = Some(anyhow!("AES-GCM response is missing tag"));
|
||||
continue;
|
||||
};
|
||||
match decrypt_v2(key, pack, tag) {
|
||||
Ok(v) => v,
|
||||
Err(err) => { last_decode_error = Some(err); continue; }
|
||||
}
|
||||
} else {
|
||||
match decrypt_v1(key, pack) {
|
||||
Ok(v) => v,
|
||||
Err(err) => { last_decode_error = Some(err); continue; }
|
||||
}
|
||||
};
|
||||
let decoded: Value = match serde_json::from_slice(&clear) {
|
||||
Ok(value) => value,
|
||||
Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; }
|
||||
};
|
||||
if binding {
|
||||
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
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); }
|
||||
bail!("GREE response timeout after 4 seconds")
|
||||
}
|
||||
|
||||
fn device_target(&self, device: &Device) -> Result<SocketAddr> {
|
||||
format!("{}:{}", device.ip, device.port).parse().context("invalid device address")
|
||||
}
|
||||
}
|
||||
+7
-416
@@ -4,419 +4,10 @@
|
||||
//! transactions and domain behavior. New queries and schema migrations should
|
||||
//! be added here instead of embedding SQL strings in other Rust modules.
|
||||
|
||||
pub const INIT_SCHEMA: &str = r#"
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
mac TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
simulated INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS climate_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
zone_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS schedules_zone_idx ON schedules(zone_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
indoor_temperature REAL,
|
||||
outdoor_temperature REAL,
|
||||
target_temperature REAL NOT NULL,
|
||||
power INTEGER NOT NULL,
|
||||
source TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS readings_device_time_idx
|
||||
ON readings(device_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zone_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
zone_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
gree_temperature REAL,
|
||||
external_temperature REAL,
|
||||
control_temperature REAL,
|
||||
target_temperature REAL,
|
||||
device_setpoint REAL,
|
||||
outdoor_temperature REAL,
|
||||
power INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
fan_speed INTEGER NOT NULL,
|
||||
demand INTEGER NOT NULL,
|
||||
control_source TEXT NOT NULL,
|
||||
active_preset TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx
|
||||
ON zone_readings(zone_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS zone_readings_time_idx
|
||||
ON zone_readings(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ha_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id TEXT NOT NULL,
|
||||
zone_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
temperature REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ha_readings_entity_time_idx
|
||||
ON ha_readings(entity_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS ha_readings_zone_time_idx
|
||||
ON ha_readings(zone_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS event_log_time_idx ON event_log(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
|
||||
|
||||
pub const UPSERT_DEVICE: &str = r#"
|
||||
INSERT INTO devices(id, mac, name, ip, simulated, payload, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mac=excluded.mac,
|
||||
name=excluded.name,
|
||||
ip=excluded.ip,
|
||||
simulated=excluded.simulated,
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLATE NOCASE";
|
||||
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
|
||||
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
|
||||
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_DEVICE_ID: &str =
|
||||
"DELETE FROM schedules WHERE zone_id IN (SELECT id FROM zones WHERE json_extract(payload, '$.device_id')=?1)";
|
||||
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
|
||||
"DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1";
|
||||
pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1";
|
||||
|
||||
pub const UPSERT_ZONE: &str = r#"
|
||||
INSERT INTO zones(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_ZONES: &str =
|
||||
"SELECT payload FROM zones ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1";
|
||||
pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1";
|
||||
|
||||
pub const UPSERT_GROUP: &str = r#"
|
||||
INSERT INTO climate_groups(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_GROUPS: &str =
|
||||
"SELECT payload FROM climate_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1";
|
||||
pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1";
|
||||
|
||||
pub const UPSERT_SCHEDULE: &str = r#"
|
||||
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
zone_id=excluded.zone_id,
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_SCHEDULES: &str =
|
||||
"SELECT payload FROM schedules ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_SCHEDULE: &str = "SELECT payload FROM schedules WHERE id=?1";
|
||||
pub const DELETE_SCHEDULE: &str = "DELETE FROM schedules WHERE id=?1";
|
||||
|
||||
pub const UPSERT_AUTOMATION: &str = r#"
|
||||
INSERT INTO automations(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_AUTOMATIONS: &str =
|
||||
"SELECT payload FROM automations ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1";
|
||||
pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1";
|
||||
|
||||
pub const INSERT_READING: &str = r#"
|
||||
INSERT INTO readings(
|
||||
device_id,
|
||||
timestamp,
|
||||
indoor_temperature,
|
||||
outdoor_temperature,
|
||||
target_temperature,
|
||||
power,
|
||||
source
|
||||
)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7)
|
||||
"#;
|
||||
|
||||
pub const LIST_READINGS_BY_DEVICE: &str = r#"
|
||||
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
|
||||
FROM readings
|
||||
WHERE device_id=?1 AND timestamp>=?2
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const LIST_READINGS_ALL: &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 LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
|
||||
AVG(target_temperature),MAX(power),MAX(source)
|
||||
FROM readings
|
||||
WHERE device_id=?1 AND timestamp>=?2
|
||||
GROUP BY device_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICE_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
|
||||
AVG(target_temperature),MAX(power),MAX(source)
|
||||
FROM readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY device_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
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#"
|
||||
INSERT INTO zone_readings(
|
||||
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
|
||||
)
|
||||
SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM zone_readings WHERE zone_id=?1 AND timestamp>=?16 LIMIT 1
|
||||
)
|
||||
"#;
|
||||
|
||||
|
||||
pub const LIST_ZONE_HISTORY_BY_ZONE_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
|
||||
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
|
||||
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
|
||||
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
|
||||
MAX(control_source),MAX(active_preset)
|
||||
FROM zone_readings
|
||||
WHERE zone_id=?1 AND timestamp>=?2
|
||||
GROUP BY zone_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_ZONE_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
|
||||
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
|
||||
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
|
||||
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
|
||||
MAX(control_source),MAX(active_preset)
|
||||
FROM zone_readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY zone_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
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#"
|
||||
INSERT INTO ha_readings(entity_id,zone_id,kind,timestamp,temperature)
|
||||
SELECT ?1,?2,?3,?4,?5
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ha_readings
|
||||
WHERE entity_id=?1 AND COALESCE(zone_id,'')=COALESCE(?2,'') AND kind=?3 AND timestamp>=?6
|
||||
LIMIT 1
|
||||
)
|
||||
"#;
|
||||
|
||||
pub const LIST_HA_HISTORY_BY_ENTITY_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
|
||||
FROM ha_readings
|
||||
WHERE entity_id=?1 AND timestamp>=?2
|
||||
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_HA_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
|
||||
FROM ha_readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
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 climate_groups;
|
||||
DELETE FROM zones;
|
||||
DELETE FROM devices;
|
||||
"#;
|
||||
pub const HISTORY_COUNTS: &str = r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM readings),
|
||||
(SELECT COUNT(*) FROM zone_readings),
|
||||
(SELECT COUNT(*) FROM ha_readings)
|
||||
"#;
|
||||
|
||||
pub const INSERT_EVENT: &str =
|
||||
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const LIST_EVENTS: &str =
|
||||
"SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1";
|
||||
pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1";
|
||||
|
||||
pub const LIST_API_TOKENS: &str =
|
||||
"SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC";
|
||||
pub const INSERT_API_TOKEN: &str =
|
||||
"INSERT INTO api_tokens(id,name,token_hash,token_prefix,created_at) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1";
|
||||
pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1";
|
||||
|
||||
pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'";
|
||||
pub const UPSERT_RUNTIME_SETTINGS: &str = r#"
|
||||
INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("queries/schema.rs");
|
||||
include!("queries/entities.rs");
|
||||
include!("queries/device_history.rs");
|
||||
include!("queries/zone_history.rs");
|
||||
include!("queries/ha_history.rs");
|
||||
include!("queries/maintenance.rs");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
pub const INSERT_READING: &str = r#"
|
||||
INSERT INTO readings(
|
||||
device_id,
|
||||
timestamp,
|
||||
indoor_temperature,
|
||||
outdoor_temperature,
|
||||
target_temperature,
|
||||
power,
|
||||
source
|
||||
)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7)
|
||||
"#;
|
||||
|
||||
pub const LIST_READINGS_BY_DEVICE: &str = r#"
|
||||
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
|
||||
FROM readings
|
||||
WHERE device_id=?1 AND timestamp>=?2
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const LIST_READINGS_ALL: &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 LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
|
||||
AVG(target_temperature),MAX(power),MAX(source)
|
||||
FROM readings
|
||||
WHERE device_id=?1 AND timestamp>=?2
|
||||
GROUP BY device_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICE_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),device_id,MIN(timestamp),AVG(indoor_temperature),AVG(outdoor_temperature),
|
||||
AVG(target_temperature),MAX(power),MAX(source)
|
||||
FROM readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY device_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
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";
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
|
||||
|
||||
pub const UPSERT_DEVICE: &str = r#"
|
||||
INSERT INTO devices(id, mac, name, ip, simulated, payload, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mac=excluded.mac,
|
||||
name=excluded.name,
|
||||
ip=excluded.ip,
|
||||
simulated=excluded.simulated,
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLATE NOCASE";
|
||||
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
|
||||
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
|
||||
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_DEVICE_ID: &str =
|
||||
"DELETE FROM schedules WHERE zone_id IN (SELECT id FROM zones WHERE json_extract(payload, '$.device_id')=?1)";
|
||||
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
|
||||
"DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1";
|
||||
pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1";
|
||||
|
||||
pub const UPSERT_ZONE: &str = r#"
|
||||
INSERT INTO zones(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_ZONES: &str =
|
||||
"SELECT payload FROM zones ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1";
|
||||
pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1";
|
||||
|
||||
pub const UPSERT_GROUP: &str = r#"
|
||||
INSERT INTO climate_groups(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_GROUPS: &str =
|
||||
"SELECT payload FROM climate_groups ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_GROUP: &str = "SELECT payload FROM climate_groups WHERE id=?1";
|
||||
pub const DELETE_GROUP: &str = "DELETE FROM climate_groups WHERE id=?1";
|
||||
|
||||
pub const UPSERT_SCHEDULE: &str = r#"
|
||||
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
zone_id=excluded.zone_id,
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_SCHEDULES: &str =
|
||||
"SELECT payload FROM schedules ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_SCHEDULE: &str = "SELECT payload FROM schedules WHERE id=?1";
|
||||
pub const DELETE_SCHEDULE: &str = "DELETE FROM schedules WHERE id=?1";
|
||||
|
||||
pub const UPSERT_AUTOMATION: &str = r#"
|
||||
INSERT INTO automations(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_AUTOMATIONS: &str =
|
||||
"SELECT payload FROM automations ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1";
|
||||
pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1";
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
pub const INSERT_HA_READING_IF_DUE: &str = r#"
|
||||
INSERT INTO ha_readings(entity_id,zone_id,kind,timestamp,temperature)
|
||||
SELECT ?1,?2,?3,?4,?5
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ha_readings
|
||||
WHERE entity_id=?1 AND COALESCE(zone_id,'')=COALESCE(?2,'') AND kind=?3 AND timestamp>=?6
|
||||
LIMIT 1
|
||||
)
|
||||
"#;
|
||||
|
||||
pub const LIST_HA_HISTORY_BY_ENTITY_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
|
||||
FROM ha_readings
|
||||
WHERE entity_id=?1 AND timestamp>=?2
|
||||
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_HA_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),entity_id,MAX(zone_id),MAX(kind),MIN(timestamp),AVG(temperature)
|
||||
FROM ha_readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY entity_id,COALESCE(zone_id,''),kind,CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
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.
|
||||
@@ -0,0 +1,70 @@
|
||||
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 climate_groups;
|
||||
DELETE FROM zones;
|
||||
DELETE FROM devices;
|
||||
"#;
|
||||
pub const HISTORY_COUNTS: &str = r#"
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM readings),
|
||||
(SELECT COUNT(*) FROM zone_readings),
|
||||
(SELECT COUNT(*) FROM ha_readings)
|
||||
"#;
|
||||
|
||||
pub const INSERT_EVENT: &str =
|
||||
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const LIST_EVENTS: &str =
|
||||
"SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1";
|
||||
pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1";
|
||||
|
||||
pub const LIST_API_TOKENS: &str =
|
||||
"SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC";
|
||||
pub const INSERT_API_TOKEN: &str =
|
||||
"INSERT INTO api_tokens(id,name,token_hash,token_prefix,created_at) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1";
|
||||
pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1";
|
||||
|
||||
pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'";
|
||||
pub const UPSERT_RUNTIME_SETTINGS: &str = r#"
|
||||
INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
@@ -0,0 +1,131 @@
|
||||
pub const INIT_SCHEMA: &str = r#"
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
mac TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
simulated INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS climate_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
zone_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS schedules_zone_idx ON schedules(zone_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
indoor_temperature REAL,
|
||||
outdoor_temperature REAL,
|
||||
target_temperature REAL NOT NULL,
|
||||
power INTEGER NOT NULL,
|
||||
source TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS readings_device_time_idx
|
||||
ON readings(device_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zone_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
zone_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
gree_temperature REAL,
|
||||
external_temperature REAL,
|
||||
control_temperature REAL,
|
||||
target_temperature REAL,
|
||||
device_setpoint REAL,
|
||||
outdoor_temperature REAL,
|
||||
power INTEGER NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
fan_speed INTEGER NOT NULL,
|
||||
demand INTEGER NOT NULL,
|
||||
control_source TEXT NOT NULL,
|
||||
active_preset TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS zone_readings_zone_time_idx
|
||||
ON zone_readings(zone_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS zone_readings_time_idx
|
||||
ON zone_readings(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ha_readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id TEXT NOT NULL,
|
||||
zone_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
temperature REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ha_readings_entity_time_idx
|
||||
ON ha_readings(entity_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS ha_readings_zone_time_idx
|
||||
ON ha_readings(zone_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS event_log_time_idx ON event_log(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (3, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (4, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (5, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
pub const INSERT_ZONE_READING_IF_DUE: &str = r#"
|
||||
INSERT INTO zone_readings(
|
||||
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
|
||||
)
|
||||
SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM zone_readings WHERE zone_id=?1 AND timestamp>=?16 LIMIT 1
|
||||
)
|
||||
"#;
|
||||
|
||||
|
||||
pub const LIST_ZONE_HISTORY_BY_ZONE_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
|
||||
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
|
||||
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
|
||||
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
|
||||
MAX(control_source),MAX(active_preset)
|
||||
FROM zone_readings
|
||||
WHERE zone_id=?1 AND timestamp>=?2
|
||||
GROUP BY zone_id, CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
|
||||
pub const LIST_ZONE_HISTORY_ALL_BUCKETED: &str = r#"
|
||||
SELECT MIN(id),zone_id,MAX(device_id),MIN(timestamp),
|
||||
AVG(gree_temperature),AVG(external_temperature),AVG(control_temperature),
|
||||
AVG(target_temperature),AVG(device_setpoint),AVG(outdoor_temperature),
|
||||
MAX(power),MAX(mode),CAST(ROUND(AVG(fan_speed)) AS INTEGER),MAX(demand),
|
||||
MAX(control_source),MAX(active_preset)
|
||||
FROM zone_readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY zone_id, CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user