v0.8.14
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user