Files
gree-controller/src/api.rs
T
2026-08-25 09:31:15 +02:00

1571 lines
77 KiB
Rust

use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
use axum::{
body::Body,
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
http::{header, HeaderMap, HeaderValue, StatusCode},
middleware::{self, Next},
response::{Redirect, Response},
routing::{get, post},
Json, Router,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::{Duration as ChronoDuration, NaiveTime, Utc};
use futures_util::StreamExt;
use rand::{rngs::OsRng, RngCore};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use serde_json::{json, Value};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use uuid::Uuid;
use crate::{
engine,
error::AppError,
home_assistant,
influxdb,
notifications,
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
const INDEX_HTML: &str = include_str!("../web/index.html");
const APP_JS: &str = include_str!("../web/app.js");
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
const STYLES_CSS: &str = include_str!("../web/styles.css");
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
const FAVICON: &str = include_str!("../web/favicon.svg");
include!(concat!(env!("OUT_DIR"), "/languages.rs"));
pub fn router(state: AppState) -> Router {
let protected = Router::new()
.route("/api/bootstrap", get(bootstrap))
.route("/api/system/info", get(system_info))
.route("/api/discovery", post(discover))
.route("/api/devices", get(list_devices).post(add_device))
.route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device))
.route("/api/devices/:id/bind", post(bind_device))
.route("/api/devices/:id/poll", post(poll_device))
.route("/api/devices/:id/command", post(command_device))
.route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
.route("/api/zones/:id/control", post(update_zone_control))
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
.route("/api/house/control", post(update_house_control))
.route("/api/house/power", post(update_house_power))
.route("/api/house/preset", post(update_house_preset))
.route("/api/schedules", get(list_schedules).post(create_schedule))
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
.route("/api/automations", get(list_automations).post(create_automation))
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
.route("/api/readings", get(readings))
.route("/api/history", get(history))
.route("/api/control-plan", get(control_plan))
.route("/api/events", get(events))
.route("/api/events/retention", get(get_event_retention).put(update_event_retention))
.route("/api/settings", get(get_settings).put(update_settings))
.route("/api/settings/export", get(export_settings))
.route("/api/settings/import", post(import_settings))
.route("/api/debug", get(get_debug).put(update_debug))
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
.route("/api/integrations/notifications/test", post(test_notifications))
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/house/control", post(update_house_control))
.route("/api/integrations/home-assistant/house/preset", post(update_house_preset))
.route("/api/integrations/home-assistant/house/power", post(update_house_power))
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
let app = Router::new()
.route("/api/health", get(health))
.route("/ws", get(websocket))
.route("/", get(index))
.route("/index.html", get(index))
.route("/app.js", get(app_js))
.route("/theme-init.js", get(theme_init_js))
.route("/styles.css", get(styles_css))
.route("/manifest.webmanifest", get(manifest))
.route("/sw.js", get(service_worker))
.route("/favicon.svg", get(favicon))
.route("/lang/index.json", get(language_index))
.route("/lang/:file", get(language_file))
.merge(protected)
.merge(home_assistant_api)
.fallback(index)
;
let app = if state.config.base_path.is_empty() {
app
} else {
let base = state.config.base_path.clone();
let redirect_to = format!("{base}/");
Router::new()
.route(&base, get(move || {
let redirect_to = redirect_to.clone();
async move { Redirect::permanent(&redirect_to) }
}))
.nest(&base, app)
};
app
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn(security_headers))
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
.with_state(state)
}
async fn debug_api_requests(State(state): State<AppState>, request: Request, next: Next) -> Response {
if !state.settings.read().await.debug.overlay_enabled {
return next.run(request).await;
}
let method = request.method().clone();
let path = request.uri().path().to_string();
let started = Instant::now();
let response = next.run(request).await;
state.broadcast("api.request", json!({
"method": method.as_str(),
"path": path,
"status": response.status().as_u16(),
"duration_ms": started.elapsed().as_millis(),
}));
response
}
async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> {
let expected = state.config.app_token.trim();
if expected.is_empty() {
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))
}
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(),
"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();
Ok(json!({
"devices": state.db.list_devices()?,
"zones": state.db.list_zones()?,
"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(),
}
}))
}
async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
let devices = state.db.list_devices()?;
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(),
"bind": state.config.bind.to_string(),
"gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() },
})))
}
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);
// 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> {
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.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
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 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_command(&state, &id, command).await?))
}
#[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,
}
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 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,
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,
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(),
}
}
}
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())); }
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 existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
let mut zone = input.into_zone(id, existing.created_at);
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.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;
let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings);
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
Ok(Json(zone))
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let schedules = state.db.list_schedules()?;
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);
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
if let Some(value) = patch.mode.as_deref() {
match value {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
zone.inherit_house_mode = false;
zone.mode = value.to_string();
}
_ => return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())),
}
}
if let Some(value) = patch.preset.as_deref() {
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 = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
}
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
}
}
if patch.clear_override.unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled { zone.enabled = value; }
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
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
}));
Ok(Json(zone))
}
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
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 mut settings = state.settings.write().await;
settings.house_mode = input.mode;
state.db.save_runtime_settings(&settings)?;
let payload = public_settings(&settings);
state.broadcast("settings.updated", payload.clone());
state.log("info", "house.mode", &format!("House mode set to {}", settings.house_mode), json!({"mode": settings.house_mode}));
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. Turning it off is
// authoritative, while house mode `off` remains a separate "do not control" state.
{
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);
}
}
let mut failed = Vec::new();
for device in state.db.list_devices()? {
if !device.enabled || device.power == input.power { continue; }
let command = DeviceCommand { power: Some(input.power), ..Default::default() };
if let Err(err) = engine::send_command(&state, &device.id, command).await {
state.log("error", "house.power_all_error", &err.to_string(), json!({
"device_id": device.id,
"device_name": device.name,
"power": input.power,
}));
failed.push(json!({
"device_id": device.id,
"device_name": device.name,
"error": err.to_string(),
}));
}
}
let devices = state.db.list_devices()?;
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 power enabled; all enabled devices powered on" } else { "Whole-house power disabled; all enabled devices powered off" }, json!({
"power": input.power,
"failed": failed.len(),
}));
Ok(Json(json!({
"power": input.power,
"devices": devices,
"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()));
}
let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?;
for zone in &mut zones {
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 = Some(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)?);
}
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({"preset": input.preset}));
Ok(Json(json!({"preset": input.preset, "zones": zones})))
}
#[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.clone(), "08:00", "23:00", "comfort");
add("Weekend sleep", weekend, "23:00", "08:00", "sleep");
}
"always" => add("Comfort", all, "00:00", "23:59", "comfort"),
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
}
state.db.replace_schedules_for_zone(&id, &items)?;
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
Ok(Json(json!({"zone": zone, "schedules": items})))
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
state.broadcast("zone.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
#[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() }
}
}
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());
state.db.save_schedule(&item)?;
state.broadcast("schedule.created", serde_json::to_value(&item)?);
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 item = input.into_schedule(id, existing.created_at);
state.db.save_schedule(&item)?;
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
Ok(Json(item))
}
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
state.broadcast("schedule.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT)
}
#[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>,
action_device_id: 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())),
}
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,
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id,
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
created_at, updated_at: Utc::now() }
}
}
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()?;
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); }
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}")))?;
if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); }
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)
}
#[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?)?))
}
#[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})))
}
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();
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; }
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();
Ok(Json(state.db.export_configuration(settings)?))
}
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
}
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
}
if export.automations.iter().any(|item| !devices.contains(item.action_device_id.as_str())) {
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
}
if export.automations.iter().any(|item| item.trigger_device_id.as_deref().is_some_and(|id| !devices.contains(id))) {
return Err(AppError::BadRequest("import contains an automation trigger referencing a missing device".into()));
}
Ok(())
}
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
validate_configuration_export(&export)?;
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.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);
state.db.replace_configuration(&export)?;
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone();
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
Ok(Json(json!({"ok": true})))
}
async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
Json(state.settings.read().await.debug.clone())
}
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
let mut settings = state.settings.write().await;
settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?;
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
state.broadcast("debug.settings", serde_json::to_value(&input)?);
Ok(Json(input))
}
#[derive(Debug, Deserialize)]
struct CreateAccessTokenRequest {
name: Option<String>,
}
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)
}
#[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())
.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})))
}
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
}
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,
},
"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,
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
"sensor_aliases": settings.home_assistant.sensor_aliases,
}
})
}
#[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,
_ => {}
}
}
}
}
}
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
}