first commit
This commit is contained in:
+689
@@ -0,0 +1,689 @@
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Html, Response},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{Duration as ChronoDuration, 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, cors::CorsLayer, trace::TraceLayer};
|
||||
use uuid::Uuid;
|
||||
use crate::{
|
||||
engine,
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||
const APP_JS: &str = include_str!("../web/app.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/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/events", get(events))
|
||||
.route("/api/settings", get(get_settings).put(update_settings))
|
||||
.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_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_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
||||
|
||||
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("/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)
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
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),
|
||||
"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(),
|
||||
})))
|
||||
}
|
||||
|
||||
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(300, 30_000);
|
||||
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
|
||||
let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms)).await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
let mut saved = Vec::new();
|
||||
for item in discovered {
|
||||
let existing = state.db.get_device_by_mac(&item.mac)?;
|
||||
let merged = merge_discovered(existing, item);
|
||||
state.db.save_device(&merged)?;
|
||||
saved.push(merged);
|
||||
}
|
||||
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len()}));
|
||||
state.broadcast("devices.discovered", json!({"devices": saved}));
|
||||
Ok(Json(json!({"count": saved.len(), "devices": saved})))
|
||||
}
|
||||
|
||||
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.clamp(1, 2),
|
||||
model: String::new(),
|
||||
firmware: String::new(),
|
||||
key: input.key.filter(|v| !v.trim().is_empty()),
|
||||
cid: Some(state.settings.read().await.controller_id.clone()),
|
||||
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,
|
||||
current_temperature: if input.simulated { Some(25.0) } else { None },
|
||||
outdoor_temperature: None,
|
||||
online: input.simulated,
|
||||
last_seen: if input.simulated { Some(now) } else { None },
|
||||
last_error: None,
|
||||
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 { device.protocol_version = v.clamp(1, 2); }
|
||||
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 key = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
device.key = Some(key);
|
||||
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 = "setpoint")]
|
||||
setpoint: f64,
|
||||
#[serde(default = "hysteresis")]
|
||||
hysteresis: f64,
|
||||
#[serde(default = "cycle")]
|
||||
min_on_seconds: u64,
|
||||
#[serde(default = "cycle")]
|
||||
min_off_seconds: u64,
|
||||
#[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 hysteresis() -> f64 { 0.6 }
|
||||
fn cycle() -> u64 { 180 }
|
||||
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())); }
|
||||
if !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 32 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 !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, setpoint: self.setpoint, hysteresis: self.hysteresis,
|
||||
min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
|
||||
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(),
|
||||
demand: false, 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 zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
|
||||
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.demand = existing.demand;
|
||||
zone.last_action_at = existing.last_action_at;
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
Ok(Json(zone))
|
||||
}
|
||||
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,
|
||||
setpoint: f64,
|
||||
}
|
||||
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 !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 32 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,
|
||||
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 * 31);
|
||||
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 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))?})))
|
||||
}
|
||||
|
||||
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);
|
||||
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.home_assistant.url.trim().is_empty() {
|
||||
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
|
||||
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
|
||||
}
|
||||
state.db.save_runtime_settings(&input)?;
|
||||
*state.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)))
|
||||
}
|
||||
|
||||
#[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 temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, input.entity_id.as_deref())
|
||||
.await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
Ok(Json(json!({"ok": true, "temperature_c": temperature})))
|
||||
}
|
||||
|
||||
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,
|
||||
"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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[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() -> Html<&'static str> { Html(INDEX_HTML) }
|
||||
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||
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,57 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use crate::models::{HomeAssistantSettings, RuntimeSettings};
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(author, version, about)]
|
||||
pub struct Config {
|
||||
#[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")]
|
||||
pub bind: SocketAddr,
|
||||
#[arg(long, env = "GREE_CONTROLLER_DATABASE", default_value = "./data/gree-controller.db")]
|
||||
pub database: PathBuf,
|
||||
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
|
||||
pub app_token: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = true)]
|
||||
pub simulate: bool,
|
||||
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = true)]
|
||||
pub auto_seed: bool,
|
||||
#[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)]
|
||||
pub poll_interval_seconds: u64,
|
||||
#[arg(long, env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", default_value_t = 5)]
|
||||
pub zone_interval_seconds: u64,
|
||||
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", default_value_t = 3000)]
|
||||
pub discovery_timeout_ms: u64,
|
||||
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000")]
|
||||
pub discovery_broadcast: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_ID", default_value = "gree-controller")]
|
||||
pub controller_id: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
dotenvy::dotenv().ok();
|
||||
let config = Self::parse();
|
||||
if let Some(parent) = config.database.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create database directory {}", parent.display()))?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn runtime_defaults(&self) -> RuntimeSettings {
|
||||
RuntimeSettings {
|
||||
controller_id: self.controller_id.clone(),
|
||||
simulator_enabled: self.simulate,
|
||||
poll_interval_seconds: self.poll_interval_seconds.max(2),
|
||||
zone_interval_seconds: self.zone_interval_seconds.max(2),
|
||||
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
|
||||
discovery_broadcast: self.discovery_broadcast.clone(),
|
||||
home_assistant: HomeAssistantSettings {
|
||||
url: env::var("HA_URL").unwrap_or_default(),
|
||||
token: env::var("HA_TOKEN").unwrap_or_default(),
|
||||
default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
use std::{path::Path, sync::{Arc, Mutex}};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone},
|
||||
queries,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = Connection::open(path)
|
||||
.with_context(|| format!("cannot open SQLite database {}", path.display()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
conn.execute_batch(queries::INIT_SCHEMA)?;
|
||||
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
|
||||
self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned"))
|
||||
}
|
||||
|
||||
fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> {
|
||||
Ok(serde_json::from_str(&payload)?)
|
||||
}
|
||||
|
||||
fn to_json<T: Serialize>(value: &T) -> Result<String> {
|
||||
Ok(serde_json::to_string(value)?)
|
||||
}
|
||||
|
||||
pub fn count_devices(&self) -> Result<u64> {
|
||||
let conn = self.lock()?;
|
||||
let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?;
|
||||
Ok(count.max(0) as u64)
|
||||
}
|
||||
|
||||
pub fn save_device(&self, device: &Device) -> Result<()> {
|
||||
let payload = Self::to_json(device)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_DEVICE,
|
||||
params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_devices(&self) -> Result<Vec<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICES)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
pub fn get_device(&self, id: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn delete_device(&self, id: &str) -> Result<bool> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn save_zone(&self, zone: &Zone) -> Result<()> {
|
||||
let payload = Self::to_json(zone)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_ZONE,
|
||||
params![zone.id, payload, zone.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_zones(&self) -> Result<Vec<Zone>> {
|
||||
self.list_payloads(queries::LIST_ZONES)
|
||||
}
|
||||
|
||||
pub fn get_zone(&self, id: &str) -> Result<Option<Zone>> {
|
||||
self.get_payload(queries::GET_ZONE, id)
|
||||
}
|
||||
|
||||
pub fn delete_zone(&self, id: &str) -> Result<bool> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?;
|
||||
let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0;
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_schedules(&self) -> Result<Vec<Schedule>> {
|
||||
self.list_payloads(queries::LIST_SCHEDULES)
|
||||
}
|
||||
|
||||
pub fn get_schedule(&self, id: &str) -> Result<Option<Schedule>> {
|
||||
self.get_payload(queries::GET_SCHEDULE, id)
|
||||
}
|
||||
|
||||
pub fn delete_schedule(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("schedules", id)
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, item: &Automation) -> Result<()> {
|
||||
let payload = Self::to_json(item)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_AUTOMATION,
|
||||
params![item.id, payload, item.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_automations(&self) -> Result<Vec<Automation>> {
|
||||
self.list_payloads(queries::LIST_AUTOMATIONS)
|
||||
}
|
||||
|
||||
pub fn get_automation(&self, id: &str) -> Result<Option<Automation>> {
|
||||
self.get_payload(queries::GET_AUTOMATION, id)
|
||||
}
|
||||
|
||||
pub fn delete_automation(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("automations", id)
|
||||
}
|
||||
|
||||
fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
fn get_payload<T: DeserializeOwned>(&self, sql: &str, id: &str) -> Result<Option<T>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(sql, [id], |row| row.get(0)).optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
fn delete_by_id(&self, table: &str, id: &str) -> Result<bool> {
|
||||
let sql = match table {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(sql, [id])? > 0)
|
||||
}
|
||||
|
||||
pub fn add_reading(&self, reading: &Reading) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_READING,
|
||||
params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature,
|
||||
reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_readings(&self, device_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit.clamp(1, 5000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<Reading> {
|
||||
let timestamp: String = row.get(2)?;
|
||||
Ok(Reading {
|
||||
id: row.get(0)?,
|
||||
device_id: row.get(1)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
indoor_temperature: row.get(3)?,
|
||||
outdoor_temperature: row.get(4)?,
|
||||
target_temperature: row.get(5)?,
|
||||
power: row.get::<_, i64>(6)? != 0,
|
||||
source: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prune_readings(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64)
|
||||
}
|
||||
|
||||
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_EVENT,
|
||||
params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_events(&self, limit: u32) -> Result<Vec<EventLog>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_EVENTS)?;
|
||||
let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| {
|
||||
let ts: String = row.get(1)?;
|
||||
let metadata: String = row.get(5)?;
|
||||
Ok(EventLog {
|
||||
id: row.get(0)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()),
|
||||
level: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
message: row.get(4)?,
|
||||
metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let created_at: String = row.get(3)?;
|
||||
Ok(ApiTokenInfo {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
token_prefix: row.get(2)?,
|
||||
created_at: DateTime::parse_from_rfc3339(&created_at)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_API_TOKEN,
|
||||
params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
let found: Option<i64> = conn.query_row(
|
||||
queries::API_TOKEN_EXISTS,
|
||||
[token_hash],
|
||||
|row| row.get(0),
|
||||
).optional()?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
pub fn delete_api_token(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
|
||||
}
|
||||
|
||||
pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> {
|
||||
let conn = self.lock()?;
|
||||
let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?;
|
||||
value.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> {
|
||||
let value = Self::to_json(settings)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_RUNTIME_SETTINGS,
|
||||
params![value, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{ApiTokenInfo, Device};
|
||||
|
||||
#[test]
|
||||
fn sqlite_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open(&dir.path().join("test.db")).unwrap();
|
||||
let device = Device::simulated_default();
|
||||
db.save_device(&device).unwrap();
|
||||
let loaded = db.get_device(&device.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.mac, device.mac);
|
||||
assert_eq!(db.list_devices().unwrap().len(), 1);
|
||||
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
|
||||
let access_token = ApiTokenInfo {
|
||||
id: "token-1".into(),
|
||||
name: "Home Assistant".into(),
|
||||
token_prefix: "gree_controller_test...".into(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
db.save_api_token(&access_token, "test-hash").unwrap();
|
||||
assert!(db.api_token_exists("test-hash").unwrap());
|
||||
assert_eq!(db.list_api_tokens().unwrap().len(), 1);
|
||||
assert!(db.delete_api_token(&access_token.id).unwrap());
|
||||
assert!(!db.api_token_exists("test-hash").unwrap());
|
||||
}
|
||||
}
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use serde_json::json;
|
||||
use tokio::time::sleep;
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn start(state: AppState) {
|
||||
let poll_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
loop {
|
||||
if let Err(err) = poll_all(&poll_state).await {
|
||||
tracing::error!(error=?err, "device poll cycle failed");
|
||||
}
|
||||
let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2);
|
||||
sleep(Duration::from_secs(seconds)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let control_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
loop {
|
||||
if let Err(err) = control_zones(&control_state).await {
|
||||
tracing::error!(error=?err, "zone cycle failed");
|
||||
}
|
||||
if let Err(err) = run_automations(&control_state).await {
|
||||
tracing::error!(error=?err, "automation cycle failed");
|
||||
}
|
||||
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
|
||||
sleep(Duration::from_secs(seconds)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let maintenance_state = state;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
sleep(Duration::from_secs(6 * 60 * 60)).await;
|
||||
match maintenance_state.db.prune_readings(30) {
|
||||
Ok(count) if count > 0 => tracing::info!(count, "old readings pruned"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
validate_command(&command)?;
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
|
||||
|
||||
if device.simulated {
|
||||
command.apply(&mut device);
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
state.db.save_device(&device)?;
|
||||
} else {
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&device).await {
|
||||
Ok(key) => {
|
||||
device.key = Some(key);
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": device.id}));
|
||||
}
|
||||
Err(err) => {
|
||||
mark_device_error(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.gree.command(&device, &command).await {
|
||||
mark_device_error(state, &mut device, &err.to_string())?;
|
||||
return Err(AppError::Device(err.to_string()));
|
||||
}
|
||||
command.apply(&mut device);
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
state.db.save_device(&device)?;
|
||||
}
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"command": command,
|
||||
}));
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
poll_device(state, &mut device).await;
|
||||
state.db.save_device(&device)?;
|
||||
record_reading(state, &device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
async fn poll_all(state: &AppState) -> Result<()> {
|
||||
for mut device in state.db.list_devices()? {
|
||||
if !device.enabled { continue; }
|
||||
poll_device(state, &mut device).await;
|
||||
state.db.save_device(&device)?;
|
||||
record_reading(state, &device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
if device.simulated {
|
||||
simulate_tick(device);
|
||||
return;
|
||||
}
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(device).await {
|
||||
Ok(key) => device.key = Some(key),
|
||||
Err(err) => {
|
||||
device.online = false;
|
||||
device.last_error = Some(err.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.gree.poll(device).await {
|
||||
device.online = false;
|
||||
device.last_error = Some(err.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
fn simulate_tick(device: &mut Device) {
|
||||
let mut current = device.current_temperature.unwrap_or(25.0);
|
||||
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
|
||||
let ambient = 25.5 + minute_wave * 0.35;
|
||||
if device.power {
|
||||
match device.mode.as_str() {
|
||||
"cool" => {
|
||||
let floor = device.target_temperature - 0.2;
|
||||
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; }
|
||||
}
|
||||
"heat" => {
|
||||
let ceiling = device.target_temperature + 0.2;
|
||||
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; }
|
||||
}
|
||||
"dry" => current -= 0.04,
|
||||
_ => current += (ambient - current) * 0.02,
|
||||
}
|
||||
} else {
|
||||
current += (ambient - current) * 0.04;
|
||||
}
|
||||
device.current_temperature = Some((current * 10.0).round() / 10.0);
|
||||
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
|
||||
// Correct rounding for outdoor temperature without accumulating precision noise.
|
||||
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
||||
state.db.add_reading(&Reading {
|
||||
id: 0,
|
||||
device_id: device.id.clone(),
|
||||
timestamp: Utc::now(),
|
||||
indoor_temperature: device.current_temperature,
|
||||
outdoor_temperature: device.outdoor_temperature,
|
||||
target_temperature: device.target_temperature,
|
||||
power: device.power,
|
||||
source: if device.simulated { "simulator".into() } else { "gree".into() },
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mark_device_error(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
|
||||
device.online = false;
|
||||
device.last_error = Some(error.to_string());
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(device)?;
|
||||
state.log("error", "device.error", &format!("{}: {error}", device.name), json!({"device_id": device.id}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
if let Some(value) = command.target_temperature {
|
||||
if !(8.0..=32.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 32 C".into())); }
|
||||
}
|
||||
if let Some(value) = command.fan_speed {
|
||||
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }
|
||||
}
|
||||
if let Some(value) = &command.mode {
|
||||
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
|
||||
return Err(AppError::BadRequest("unsupported HVAC mode".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if !zone.enabled { continue; }
|
||||
if let Some(setpoint) = active_setpoint(&zone, &schedules, Local::now()) {
|
||||
zone.setpoint = setpoint;
|
||||
}
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else {
|
||||
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
|
||||
continue;
|
||||
};
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
let device_temperature = device.current_temperature;
|
||||
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()}));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
|
||||
zone.device_temperature = device_temperature;
|
||||
zone.external_temperature = external_temperature;
|
||||
zone.current_temperature = temperature;
|
||||
zone.control_temperature_source = control_source;
|
||||
zone.updated_at = Utc::now();
|
||||
|
||||
if discrepancy && previous_source != "device_discrepancy_fallback" {
|
||||
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
|
||||
"zone_id": zone.id,
|
||||
"device_temperature": zone.device_temperature,
|
||||
"external_temperature": zone.external_temperature,
|
||||
"max_difference": zone.max_sensor_difference,
|
||||
"entity_id": zone.ha_entity_id.as_deref(),
|
||||
}));
|
||||
}
|
||||
|
||||
let Some(temp) = temperature else { state.db.save_zone(&zone)?; continue; };
|
||||
|
||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||
let desired = match zone.mode.as_str() {
|
||||
"heat" => if temp <= zone.setpoint - half { Some(true) } else if temp >= zone.setpoint + half { Some(false) } else { None },
|
||||
_ => if temp >= zone.setpoint + half { Some(true) } else if temp <= zone.setpoint - half { Some(false) } else { None },
|
||||
};
|
||||
if let Some(on) = desired {
|
||||
zone.demand = on;
|
||||
if on != device.power && cycle_allowed(&zone, device.power) {
|
||||
let command = DeviceCommand {
|
||||
power: Some(on),
|
||||
mode: if on { Some(zone.mode.clone()) } else { None },
|
||||
target_temperature: if on { Some(zone.setpoint) } else { None },
|
||||
..Default::default()
|
||||
};
|
||||
match send_command(state, &zone.device_id, command).await {
|
||||
Ok(_) => {
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "zone.action", &format!("Zone {} demand {}", zone.name, if on { "ON" } else { "OFF" }), json!({
|
||||
"zone_id": zone.id, "temperature": temp, "setpoint": zone.setpoint,
|
||||
}));
|
||||
}
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
}
|
||||
}
|
||||
}
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
|
||||
match zone.sensor_source.as_str() {
|
||||
"home_assistant" => match (external_temperature, device_temperature) {
|
||||
(Some(value), _) => (Some(value), "external".into(), false),
|
||||
(None, Some(value)) => (Some(value), "device_fallback".into(), false),
|
||||
(None, None) => (None, "unavailable".into(), false),
|
||||
},
|
||||
"combined" => match (device_temperature, external_temperature) {
|
||||
(Some(device), Some(external)) => {
|
||||
if (device - external).abs() > zone.max_sensor_difference.max(0.1) {
|
||||
(Some(device), "device_discrepancy_fallback".into(), true)
|
||||
} else {
|
||||
let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0);
|
||||
let value = device * (1.0 - external_weight) + external * external_weight;
|
||||
(Some((value * 10.0).round() / 10.0), "combined".into(), false)
|
||||
}
|
||||
}
|
||||
(Some(value), None) => (Some(value), "device_fallback".into(), false),
|
||||
(None, Some(value)) => (Some(value), "external".into(), false),
|
||||
(None, None) => (None, "unavailable".into(), false),
|
||||
},
|
||||
_ => match device_temperature {
|
||||
Some(value) => (Some(value), "device".into(), false),
|
||||
None => (None, "unavailable".into(), false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_allowed(zone: &Zone, currently_on: bool) -> bool {
|
||||
let Some(last) = zone.last_action_at else { return true; };
|
||||
let elapsed = (Utc::now() - last).num_seconds().max(0) as u64;
|
||||
if currently_on { elapsed >= zone.min_on_seconds } else { elapsed >= zone.min_off_seconds }
|
||||
}
|
||||
|
||||
fn active_setpoint(zone: &Zone, schedules: &[Schedule], now: DateTime<Local>) -> Option<f64> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
.last()
|
||||
.map(|item| item.setpoint)
|
||||
}
|
||||
|
||||
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
|
||||
let time = now.time();
|
||||
let today = now.weekday().number_from_monday();
|
||||
if start <= end {
|
||||
item.weekdays.contains(&today) && time >= start && time < end
|
||||
} else if time >= start {
|
||||
item.weekdays.contains(&today)
|
||||
} else if time < end {
|
||||
let previous = previous_weekday(now.weekday()).number_from_monday();
|
||||
item.weekdays.contains(&previous)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_weekday(day: Weekday) -> Weekday {
|
||||
match day {
|
||||
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
|
||||
Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri,
|
||||
Weekday::Sun => Weekday::Sat,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
let devices = state.db.list_devices()?;
|
||||
for mut item in state.db.list_automations()? {
|
||||
if !item.enabled || !automation_ready(&item) { continue; }
|
||||
let should_fire = match item.trigger_kind.as_str() {
|
||||
"temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
||||
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
||||
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
||||
"time" => item.at_time.as_deref().map(time_matches).unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if !should_fire { continue; }
|
||||
match send_command(state, &item.action_device_id, item.action.clone()).await {
|
||||
Ok(_) => {
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({"automation_id": item.id}));
|
||||
}
|
||||
Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
||||
let id = device_id?;
|
||||
devices.iter().find(|d| d.id == id)?.current_temperature
|
||||
}
|
||||
|
||||
fn automation_ready(item: &Automation) -> bool {
|
||||
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
||||
}
|
||||
|
||||
fn time_matches(expected: &str) -> bool {
|
||||
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
||||
let now = Local::now().time();
|
||||
now.hour() == value.hour() && now.minute() == value.minute()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[test]
|
||||
fn overnight_schedule_works() {
|
||||
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
|
||||
let item = Schedule {
|
||||
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
|
||||
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), setpoint: 20.0,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
};
|
||||
assert!(schedule_active(&item, now));
|
||||
}
|
||||
|
||||
fn test_zone(source: &str) -> Zone {
|
||||
Zone {
|
||||
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
||||
mode: "heat".into(), setpoint: 21.0, hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180,
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None,
|
||||
current_temperature: None, control_temperature_source: "device".into(), demand: false, last_action_at: None,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_prefers_room_sensor_weight() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0));
|
||||
assert_eq!(value, Some(21.2));
|
||||
assert_eq!(source, "combined");
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_falls_back_on_large_discrepancy() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0));
|
||||
assert_eq!(value, Some(21.0));
|
||||
assert_eq!(source, "device_discrepancy_fallback");
|
||||
assert!(discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_temperature_falls_back_when_external_is_missing() {
|
||||
let zone = test_zone("combined");
|
||||
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None);
|
||||
assert_eq!(value, Some(21.5));
|
||||
assert_eq!(source, "device_fallback");
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
|
||||
use serde_json::json;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("invalid request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("device communication failed: {0}")]
|
||||
Device(String),
|
||||
#[error(transparent)]
|
||||
Internal(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(v) => (StatusCode::NOT_FOUND, v.clone()),
|
||||
Self::BadRequest(v) => (StatusCode::BAD_REQUEST, v.clone()),
|
||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
|
||||
Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()),
|
||||
Self::Internal(v) => {
|
||||
tracing::error!(error = ?v, "internal error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into())
|
||||
}
|
||||
};
|
||||
(status, Json(json!({"error": message}))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(value: rusqlite::Error) -> Self { Self::Internal(value.into()) }
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AppError {
|
||||
fn from(value: serde_json::Error) -> Self { Self::Internal(value.into()) }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
use crate::models::HomeAssistantSettings;
|
||||
|
||||
pub async fn read_temperature(
|
||||
client: &reqwest::Client,
|
||||
settings: &HomeAssistantSettings,
|
||||
entity_override: Option<&str>,
|
||||
) -> Result<f64> {
|
||||
if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") }
|
||||
if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") }
|
||||
let entity = entity_override.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or(settings.default_entity_id.trim());
|
||||
if entity.is_empty() { bail!("Home Assistant entity_id is not configured") }
|
||||
|
||||
let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?;
|
||||
if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") }
|
||||
let path = format!("api/states/{}", entity.trim_start_matches('/'));
|
||||
base = base.join(&path).context("cannot build Home Assistant API URL")?;
|
||||
|
||||
let response = client.get(base)
|
||||
.bearer_auth(settings.token.trim())
|
||||
.header("Accept", "application/json")
|
||||
.send().await.context("Home Assistant request failed")?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::<String>())
|
||||
}
|
||||
let payload: Value = response.json().await.context("invalid Home Assistant JSON")?;
|
||||
let state = payload.get("state").and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("Home Assistant state is missing"))?;
|
||||
let mut temperature: f64 = state.parse().context("Home Assistant state is not a number")?;
|
||||
let unit = payload.pointer("/attributes/unit_of_measurement").and_then(Value::as_str).unwrap_or("°C");
|
||||
if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") {
|
||||
temperature = (temperature - 32.0) * 5.0 / 9.0;
|
||||
}
|
||||
Ok((temperature * 10.0).round() / 10.0)
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
mod api;
|
||||
mod config;
|
||||
mod db;
|
||||
mod engine;
|
||||
mod error;
|
||||
mod home_assistant;
|
||||
mod models;
|
||||
mod protocol;
|
||||
mod queries;
|
||||
mod state;
|
||||
|
||||
use std::{sync::Arc, time::{Duration, Instant}};
|
||||
use anyhow::{Context, Result};
|
||||
use config::Config;
|
||||
use db::Db;
|
||||
use models::Device;
|
||||
use protocol::GreeClient;
|
||||
use state::AppState;
|
||||
use tokio::{net::TcpListener, signal, sync::{broadcast, RwLock}};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
init_tracing();
|
||||
|
||||
let db = Db::open(&config.database)?;
|
||||
let runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults());
|
||||
db.save_runtime_settings(&runtime_settings)?;
|
||||
|
||||
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
|
||||
db.save_device(&Device::simulated_default())?;
|
||||
db.log_event(
|
||||
"info",
|
||||
"simulator.seeded",
|
||||
"Created the default simulator device",
|
||||
&serde_json::json!({"device_id":"sim-salon"}),
|
||||
)?;
|
||||
}
|
||||
|
||||
let (events, _) = broadcast::channel(256);
|
||||
let http = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
|
||||
.build()?;
|
||||
let state = AppState {
|
||||
db,
|
||||
settings: Arc::new(RwLock::new(runtime_settings.clone())),
|
||||
config: Arc::new(config.clone()),
|
||||
gree: GreeClient::new(runtime_settings.controller_id.clone()),
|
||||
events,
|
||||
http,
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
engine::start(state.clone());
|
||||
let app = api::router(state.clone());
|
||||
let listener = TcpListener::bind(config.bind).await
|
||||
.with_context(|| format!("cannot bind HTTP server to {}", config.bind))?;
|
||||
|
||||
tracing::info!(
|
||||
address = %config.bind,
|
||||
database = %config.database.display(),
|
||||
simulator = config.simulate,
|
||||
auth = !config.app_token.trim().is_empty(),
|
||||
"GREE Controller started"
|
||||
);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
tracing::info!("GREE Controller stopped");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info,tower_http=info".into());
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(tracing_subscriber::fmt::layer().compact())
|
||||
.init();
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async { signal::ctrl_c().await.expect("cannot install Ctrl+C handler"); };
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("cannot install SIGTERM handler")
|
||||
.recv().await;
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
fn default_true() -> bool { true }
|
||||
fn default_port() -> u16 { 7000 }
|
||||
fn default_protocol() -> u8 { 1 }
|
||||
fn default_mode() -> String { "cool".into() }
|
||||
fn default_fan() -> u8 { 0 }
|
||||
fn default_target() -> f64 { 24.0 }
|
||||
fn default_hysteresis() -> f64 { 0.6 }
|
||||
fn default_external_sensor_weight() -> f64 { 0.4 }
|
||||
fn default_max_sensor_difference() -> f64 { 3.0 }
|
||||
fn default_control_temperature_source() -> String { "device".into() }
|
||||
fn default_min_cycle() -> u64 { 180 }
|
||||
fn default_cooldown() -> u64 { 300 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
pub id: String,
|
||||
pub mac: String,
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub protocol_version: u8,
|
||||
#[serde(default)]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub firmware: String,
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cid: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub simulated: bool,
|
||||
#[serde(default)]
|
||||
pub power: bool,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(default = "default_target")]
|
||||
pub target_temperature: f64,
|
||||
#[serde(default = "default_fan")]
|
||||
pub fan_speed: u8,
|
||||
#[serde(default)]
|
||||
pub swing_vertical: bool,
|
||||
#[serde(default)]
|
||||
pub swing_horizontal: bool,
|
||||
#[serde(default)]
|
||||
pub quiet: bool,
|
||||
#[serde(default)]
|
||||
pub turbo: bool,
|
||||
#[serde(default)]
|
||||
pub light: bool,
|
||||
#[serde(default)]
|
||||
pub current_temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub online: bool,
|
||||
#[serde(default)]
|
||||
pub last_seen: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Device {
|
||||
pub fn simulated_default() -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: "sim-salon".into(),
|
||||
mac: "SIM000000001".into(),
|
||||
name: "Living Room (simulator)".into(),
|
||||
ip: "127.0.0.1".into(),
|
||||
port: 7000,
|
||||
protocol_version: 1,
|
||||
model: "GREE-SIM".into(),
|
||||
firmware: "sim-1.0".into(),
|
||||
key: None,
|
||||
cid: Some("gree-controller".into()),
|
||||
enabled: true,
|
||||
simulated: true,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 23.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
quiet: false,
|
||||
turbo: false,
|
||||
light: true,
|
||||
current_temperature: Some(26.0),
|
||||
outdoor_temperature: Some(30.0),
|
||||
online: true,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DevicePatch {
|
||||
pub name: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub protocol_version: Option<u8>,
|
||||
pub key: Option<Option<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DeviceCommand {
|
||||
pub power: Option<bool>,
|
||||
pub mode: Option<String>,
|
||||
pub target_temperature: Option<f64>,
|
||||
pub fan_speed: Option<u8>,
|
||||
pub swing_vertical: Option<bool>,
|
||||
pub swing_horizontal: Option<bool>,
|
||||
pub quiet: Option<bool>,
|
||||
pub turbo: Option<bool>,
|
||||
pub light: Option<bool>,
|
||||
}
|
||||
|
||||
impl DeviceCommand {
|
||||
pub fn apply(&self, device: &mut Device) {
|
||||
if let Some(v) = self.power { device.power = v; }
|
||||
if let Some(v) = &self.mode { device.mode = v.clone(); }
|
||||
if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 32.0); }
|
||||
if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); }
|
||||
if let Some(v) = self.swing_vertical { device.swing_vertical = v; }
|
||||
if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; }
|
||||
if let Some(v) = self.quiet { device.quiet = v; }
|
||||
if let Some(v) = self.turbo { device.turbo = v; }
|
||||
if let Some(v) = self.light { device.light = v; }
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Zone {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub device_id: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
#[serde(default = "default_target")]
|
||||
pub setpoint: f64,
|
||||
#[serde(default = "default_hysteresis")]
|
||||
pub hysteresis: f64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_on_seconds: u64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_off_seconds: u64,
|
||||
#[serde(default = "default_sensor_source")]
|
||||
pub sensor_source: String,
|
||||
#[serde(default)]
|
||||
pub ha_entity_id: Option<String>,
|
||||
/// Weight of the optional room sensor when sensor_source is `combined`.
|
||||
#[serde(default = "default_external_sensor_weight")]
|
||||
pub external_sensor_weight: f64,
|
||||
/// If GREE and external sensor differ more than this, the controller falls back to GREE.
|
||||
#[serde(default = "default_max_sensor_difference")]
|
||||
pub max_sensor_difference: f64,
|
||||
/// Temperature reported by the GREE indoor sensor during the last zone cycle.
|
||||
#[serde(default)]
|
||||
pub device_temperature: Option<f64>,
|
||||
/// Temperature reported by the per-zone external Home Assistant sensor.
|
||||
#[serde(default)]
|
||||
pub external_temperature: Option<f64>,
|
||||
/// Temperature actually used by the zone controller.
|
||||
#[serde(default)]
|
||||
pub current_temperature: Option<f64>,
|
||||
/// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`.
|
||||
#[serde(default = "default_control_temperature_source")]
|
||||
pub control_temperature_source: String,
|
||||
#[serde(default)]
|
||||
pub demand: bool,
|
||||
#[serde(default)]
|
||||
pub last_action_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_sensor_source() -> String { "device".into() }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Schedule {
|
||||
pub id: String,
|
||||
pub zone_id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// ISO weekday numbers, Monday=1, Sunday=7.
|
||||
pub weekdays: Vec<u32>,
|
||||
/// Local time HH:MM.
|
||||
pub start_time: String,
|
||||
/// Local time HH:MM. Ranges crossing midnight are supported.
|
||||
pub end_time: String,
|
||||
pub setpoint: f64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Automation {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// temperature_above, temperature_below, time
|
||||
pub trigger_kind: String,
|
||||
#[serde(default)]
|
||||
pub trigger_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub threshold: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub at_time: Option<String>,
|
||||
pub action_device_id: String,
|
||||
#[serde(default)]
|
||||
pub action: DeviceCommand,
|
||||
#[serde(default = "default_cooldown")]
|
||||
pub cooldown_seconds: u64,
|
||||
#[serde(default)]
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Reading {
|
||||
pub id: i64,
|
||||
pub device_id: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub indoor_temperature: Option<f64>,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub target_temperature: f64,
|
||||
pub power: bool,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventLog {
|
||||
pub id: i64,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub level: String,
|
||||
pub kind: String,
|
||||
pub message: String,
|
||||
pub metadata: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HomeAssistantSettings {
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub default_entity_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RuntimeSettings {
|
||||
pub controller_id: String,
|
||||
pub simulator_enabled: bool,
|
||||
pub poll_interval_seconds: u64,
|
||||
pub zone_interval_seconds: u64,
|
||||
pub discovery_timeout_ms: u64,
|
||||
pub discovery_broadcast: String,
|
||||
pub home_assistant: HomeAssistantSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoveryRequest {
|
||||
#[serde(default)]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub broadcast: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManualDeviceRequest {
|
||||
pub name: String,
|
||||
pub mac: String,
|
||||
pub ip: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub protocol_version: u8,
|
||||
#[serde(default)]
|
||||
pub key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub simulated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiTokenInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub token_prefix: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiEvent {
|
||||
pub event: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub data: Value,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}};
|
||||
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use rand::RngCore;
|
||||
|
||||
pub const GENERIC_GREE_KEY: &str = "a3K8Bx%2r8Y7cB!a";
|
||||
|
||||
pub fn normalize_key(key: &str) -> Result<[u8; 16]> {
|
||||
let bytes = key.as_bytes();
|
||||
if bytes.len() == 16 {
|
||||
let mut out = [0_u8; 16];
|
||||
out.copy_from_slice(bytes);
|
||||
return Ok(out);
|
||||
}
|
||||
if let Ok(decoded) = STANDARD.decode(key) {
|
||||
if decoded.len() == 16 {
|
||||
let mut out = [0_u8; 16];
|
||||
out.copy_from_slice(&decoded);
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
bail!("GREE key must contain 16 bytes or base64-encoded 16 bytes")
|
||||
}
|
||||
|
||||
pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
|
||||
let pad = 16 - (plaintext.len() % 16);
|
||||
let mut data = Vec::with_capacity(plaintext.len() + pad);
|
||||
data.extend_from_slice(plaintext);
|
||||
data.extend(std::iter::repeat(pad as u8).take(pad));
|
||||
for block in data.chunks_exact_mut(16) {
|
||||
cipher.encrypt_block(GenericArray::from_mut_slice(block));
|
||||
}
|
||||
Ok(STANDARD.encode(data))
|
||||
}
|
||||
|
||||
pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
|
||||
let mut data = STANDARD.decode(ciphertext_b64).context("invalid base64 packet")?;
|
||||
if data.is_empty() || data.len() % 16 != 0 {
|
||||
bail!("invalid AES-ECB ciphertext length")
|
||||
}
|
||||
for block in data.chunks_exact_mut(16) {
|
||||
cipher.decrypt_block(GenericArray::from_mut_slice(block));
|
||||
}
|
||||
let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize;
|
||||
if pad == 0 || pad > 16 || data.len() < pad || data[data.len() - pad..].iter().any(|v| *v as usize != pad) {
|
||||
bail!("invalid PKCS#7 padding")
|
||||
}
|
||||
data.truncate(data.len() - pad);
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct V2Encrypted {
|
||||
pub ciphertext: String,
|
||||
pub nonce: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||
let mut nonce_bytes = [0_u8; 12];
|
||||
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let mut buffer = plaintext.to_vec();
|
||||
let tag = cipher.encrypt_in_place_detached(nonce, b"", &mut buffer)
|
||||
.map_err(|_| anyhow!("AES-GCM encryption failed"))?;
|
||||
Ok(V2Encrypted {
|
||||
ciphertext: STANDARD.encode(buffer),
|
||||
nonce: STANDARD.encode(nonce_bytes),
|
||||
tag: STANDARD.encode(tag),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decrypt_v2(key: &str, ciphertext_b64: &str, nonce_b64: &str, tag_b64: &str) -> Result<Vec<u8>> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||
let nonce_bytes = STANDARD.decode(nonce_b64).context("invalid GCM nonce")?;
|
||||
if nonce_bytes.len() != 12 { bail!("invalid GCM nonce length") }
|
||||
let tag_bytes = STANDARD.decode(tag_b64).context("invalid GCM tag")?;
|
||||
if tag_bytes.len() != 16 { bail!("invalid GCM tag length") }
|
||||
let mut data = STANDARD.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let tag = GenericArray::from_slice(&tag_bytes);
|
||||
cipher.decrypt_in_place_detached(nonce, b"", &mut data, tag)
|
||||
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v1_round_trip() {
|
||||
let value = br#"{"t":"status","mac":"112233445566"}"#;
|
||||
let encrypted = encrypt_v1(GENERIC_GREE_KEY, value).unwrap();
|
||||
assert_eq!(decrypt_v1(GENERIC_GREE_KEY, &encrypted).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_round_trip() {
|
||||
let value = b"gree-gcm-test";
|
||||
let encrypted = encrypt_v2(GENERIC_GREE_KEY, value).unwrap();
|
||||
assert_eq!(decrypt_v2(GENERIC_GREE_KEY, &encrypted.ciphertext, &encrypted.nonce, &encrypted.tag).unwrap(), value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
use std::{collections::HashSet, net::SocketAddr, sync::{Arc, atomic::{AtomicU64, Ordering}}, time::Duration};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::{net::UdpSocket, time::{timeout, Instant}};
|
||||
use uuid::Uuid;
|
||||
use crate::models::{Device, DeviceCommand};
|
||||
use super::crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_KEY};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GreeClient {
|
||||
controller_id: String,
|
||||
sequence: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl GreeClient {
|
||||
pub fn new(controller_id: String) -> Self {
|
||||
Self { controller_id, sequence: Arc::new(AtomicU64::new(1)) }
|
||||
}
|
||||
|
||||
fn next_id(&self) -> u64 { self.sequence.fetch_add(1, Ordering::Relaxed) }
|
||||
|
||||
pub async fn discover(&self, broadcast: &str, duration: Duration) -> Result<Vec<Device>> {
|
||||
let target: SocketAddr = broadcast.parse().context("invalid discovery broadcast address")?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.set_broadcast(true)?;
|
||||
socket.send_to(br#"{"t":"scan"}"#, target).await?;
|
||||
|
||||
let deadline = Instant::now() + duration;
|
||||
let mut result = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut buffer = vec![0_u8; 8192];
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match timeout(remaining.min(Duration::from_millis(450)), socket.recv_from(&mut buffer)).await {
|
||||
Ok(Ok((size, source))) => {
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[..size]) {
|
||||
if let Some(mut device) = self.parse_discovery(value, source) {
|
||||
let key = device.mac.to_ascii_lowercase();
|
||||
if seen.insert(key) {
|
||||
device.last_seen = Some(Utc::now());
|
||||
result.push(device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => return Err(err.into()),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Option<Device> {
|
||||
if value.get("t").and_then(Value::as_str) == Some("pack") {
|
||||
if let Some(pack) = value.get("pack").and_then(Value::as_str) {
|
||||
if let Ok(clear) = decrypt_v1(GENERIC_GREE_KEY, pack) {
|
||||
if let Ok(inner) = serde_json::from_slice::<Value>(&clear) { value = inner; }
|
||||
}
|
||||
}
|
||||
}
|
||||
let kind = value.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||
if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() {
|
||||
return None;
|
||||
}
|
||||
let mac = value.get("mac").or_else(|| value.get("cid"))?.as_str()?.replace(':', "");
|
||||
if mac.is_empty() { return None; }
|
||||
let name = value.get("name").and_then(Value::as_str)
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or("Klimatyzator GREE").to_string();
|
||||
let model = value.get("model").or_else(|| value.get("series"))
|
||||
.and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let firmware = value.get("ver").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let protocol_version = value.get("protocol").and_then(Value::as_u64)
|
||||
.or_else(|| value.get("v").and_then(Value::as_u64))
|
||||
.map(|v| v as u8)
|
||||
.unwrap_or_else(|| if value.get("tag").is_some() || value.get("nonce").is_some() { 2 } else { 1 });
|
||||
let now = Utc::now();
|
||||
Some(Device {
|
||||
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
||||
mac,
|
||||
name,
|
||||
ip: source.ip().to_string(),
|
||||
port: source.port(),
|
||||
protocol_version,
|
||||
model,
|
||||
firmware,
|
||||
key: None,
|
||||
cid: Some(self.controller_id.clone()),
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
quiet: false,
|
||||
turbo: false,
|
||||
light: true,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
online: true,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn bind(&self, device: &Device) -> Result<String> {
|
||||
let inner = json!({"mac": device.mac, "t": "bind", "uid": 0});
|
||||
let response = self.request(device, &inner, GENERIC_GREE_KEY, true).await?;
|
||||
let key = response.get("key").and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("bind response does not contain device key"))?;
|
||||
if key.is_empty() { bail!("device returned an empty key") }
|
||||
Ok(key.to_string())
|
||||
}
|
||||
|
||||
pub async fn poll(&self, device: &mut Device) -> Result<()> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let cols = [
|
||||
"Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig",
|
||||
"SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType",
|
||||
"TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem"
|
||||
];
|
||||
let inner = json!({"cols": cols, "mac": device.mac, "t": "status"});
|
||||
let response = self.request(device, &inner, key, false).await?;
|
||||
let response_cols = response.get("cols").and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("status response has no cols"))?;
|
||||
let data = response.get("dat").and_then(Value::as_array)
|
||||
.ok_or_else(|| anyhow!("status response has no dat"))?;
|
||||
for (name, value) in response_cols.iter().zip(data.iter()) {
|
||||
let Some(name) = name.as_str() else { continue; };
|
||||
match name {
|
||||
"Pow" => device.power = value_as_i64(value) != 0,
|
||||
"Mod" => device.mode = mode_name(value_as_i64(value)).into(),
|
||||
"SetTem" => device.target_temperature = value_as_f64(value).clamp(8.0, 32.0),
|
||||
"WdSpd" => device.fan_speed = value_as_i64(value).clamp(0, 5) as u8,
|
||||
"SwUpDn" => device.swing_vertical = value_as_i64(value) != 0,
|
||||
"SwingLfRig" => device.swing_horizontal = value_as_i64(value) != 0,
|
||||
"Quiet" => device.quiet = value_as_i64(value) != 0,
|
||||
"Tur" => device.turbo = value_as_i64(value) != 0,
|
||||
"Lig" => device.light = value_as_i64(value) != 0,
|
||||
"TemSen" => {
|
||||
let raw = value_as_f64(value);
|
||||
device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
device.online = true;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn command(&self, device: &Device, command: &DeviceCommand) -> Result<Value> {
|
||||
let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?;
|
||||
let mut opt = Vec::<&str>::new();
|
||||
let mut values = Vec::<Value>::new();
|
||||
if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); }
|
||||
if let Some(v) = command.target_temperature { opt.push("SetTem"); values.push(json!(v.clamp(8.0, 32.0).round() as i64)); }
|
||||
if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); }
|
||||
if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); }
|
||||
if opt.is_empty() { bail!("empty device command") }
|
||||
let inner = json!({"opt": opt, "p": values, "t": "cmd"});
|
||||
self.request(device, &inner, key, false).await
|
||||
}
|
||||
|
||||
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool) -> Result<Value> {
|
||||
let target: SocketAddr = format!("{}:{}", device.ip, device.port).parse()
|
||||
.context("invalid device address")?;
|
||||
let inner_bytes = serde_json::to_vec(inner)?;
|
||||
let mut outer = json!({
|
||||
"cid": self.controller_id,
|
||||
"i": self.next_id(),
|
||||
"t": "pack",
|
||||
"tcid": device.mac,
|
||||
"uid": 0
|
||||
});
|
||||
if device.protocol_version >= 2 && !binding {
|
||||
let encrypted = encrypt_v2(key, &inner_bytes)?;
|
||||
outer["pack"] = json!(encrypted.ciphertext);
|
||||
outer["nonce"] = json!(encrypted.nonce);
|
||||
outer["tag"] = json!(encrypted.tag);
|
||||
} else {
|
||||
outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?);
|
||||
}
|
||||
let payload = serde_json::to_vec(&outer)?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.send_to(&payload, target).await?;
|
||||
let mut buffer = vec![0_u8; 16 * 1024];
|
||||
let (size, _) = timeout(Duration::from_secs(4), socket.recv_from(&mut buffer))
|
||||
.await.context("GREE response timeout")??;
|
||||
let response: Value = serde_json::from_slice(&buffer[..size]).context("invalid GREE JSON response")?;
|
||||
let pack = response.get("pack").and_then(Value::as_str)
|
||||
.ok_or_else(|| anyhow!("GREE response does not contain encrypted pack"))?;
|
||||
let clear = if let (Some(nonce), Some(tag)) = (
|
||||
response.get("nonce").and_then(Value::as_str),
|
||||
response.get("tag").and_then(Value::as_str),
|
||||
) {
|
||||
decrypt_v2(key, pack, nonce, tag)?
|
||||
} else {
|
||||
decrypt_v1(key, pack)?
|
||||
};
|
||||
let decoded: Value = serde_json::from_slice(&clear).context("invalid decrypted GREE response")?;
|
||||
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) {
|
||||
bail!("GREE device error: {err}")
|
||||
}
|
||||
Ok(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
fn value_as_i64(value: &Value) -> i64 {
|
||||
value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn value_as_f64(value: &Value) -> f64 {
|
||||
value.as_f64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn mode_name(value: i64) -> &'static str {
|
||||
match value { 0 => "auto", 1 => "cool", 2 => "dry", 3 => "fan", 4 => "heat", _ => "auto" }
|
||||
}
|
||||
|
||||
fn mode_value(value: &str) -> Result<i64> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4),
|
||||
_ => bail!("unsupported mode: {value}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_discovered(existing: Option<Device>, discovered: Device) -> Device {
|
||||
if let Some(mut old) = existing {
|
||||
old.ip = discovered.ip;
|
||||
old.port = discovered.port;
|
||||
if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" { old.name = discovered.name; }
|
||||
if !discovered.model.is_empty() { old.model = discovered.model; }
|
||||
if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; }
|
||||
old.protocol_version = discovered.protocol_version;
|
||||
old.online = true;
|
||||
old.last_seen = Some(Utc::now());
|
||||
old.last_error = None;
|
||||
old.updated_at = Utc::now();
|
||||
old
|
||||
} else {
|
||||
let mut new = discovered;
|
||||
if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); }
|
||||
new
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod crypto;
|
||||
pub mod gree;
|
||||
|
||||
pub use gree::{GreeClient, merge_discovered};
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
//! Centralized SQLite statements used by the controller.
|
||||
//!
|
||||
//! Keep SQL in this module so database access code stays focused on mapping,
|
||||
//! transactions and domain behavior. New queries and schema migrations should
|
||||
//! be added here instead of embedding SQL strings in other Rust modules.
|
||||
|
||||
pub const INIT_SCHEMA: &str = r#"
|
||||
PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
mac TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
simulated INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS zones (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id TEXT PRIMARY KEY,
|
||||
zone_id TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS schedules_zone_idx ON schedules(zone_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS automations (
|
||||
id TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS readings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
indoor_temperature REAL,
|
||||
outdoor_temperature REAL,
|
||||
target_temperature REAL NOT NULL,
|
||||
power INTEGER NOT NULL,
|
||||
source TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS readings_device_time_idx
|
||||
ON readings(device_id, timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS event_log_time_idx ON event_log(timestamp DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
INSERT OR IGNORE INTO schema_migrations(version, applied_at)
|
||||
VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now'));
|
||||
"#;
|
||||
|
||||
pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices";
|
||||
|
||||
pub const UPSERT_DEVICE: &str = r#"
|
||||
INSERT INTO devices(id, mac, name, ip, simulated, payload, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mac=excluded.mac,
|
||||
name=excluded.name,
|
||||
ip=excluded.ip,
|
||||
simulated=excluded.simulated,
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
|
||||
pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLATE NOCASE";
|
||||
pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1";
|
||||
pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)";
|
||||
pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1";
|
||||
pub const DELETE_ZONES_BY_DEVICE_ID: &str =
|
||||
"DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1";
|
||||
pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1";
|
||||
|
||||
pub const UPSERT_ZONE: &str = r#"
|
||||
INSERT INTO zones(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_ZONES: &str =
|
||||
"SELECT payload FROM zones ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1";
|
||||
pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1";
|
||||
pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1";
|
||||
|
||||
pub const UPSERT_SCHEDULE: &str = r#"
|
||||
INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
zone_id=excluded.zone_id,
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_SCHEDULES: &str =
|
||||
"SELECT payload FROM schedules ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_SCHEDULE: &str = "SELECT payload FROM schedules WHERE id=?1";
|
||||
pub const DELETE_SCHEDULE: &str = "DELETE FROM schedules WHERE id=?1";
|
||||
|
||||
pub const UPSERT_AUTOMATION: &str = r#"
|
||||
INSERT INTO automations(id,payload,updated_at) VALUES(?1,?2,?3)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
payload=excluded.payload,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
pub const LIST_AUTOMATIONS: &str =
|
||||
"SELECT payload FROM automations ORDER BY json_extract(payload, '$.name') COLLATE NOCASE";
|
||||
pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1";
|
||||
pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1";
|
||||
|
||||
pub const INSERT_READING: &str = r#"
|
||||
INSERT INTO readings(
|
||||
device_id,
|
||||
timestamp,
|
||||
indoor_temperature,
|
||||
outdoor_temperature,
|
||||
target_temperature,
|
||||
power,
|
||||
source
|
||||
)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7)
|
||||
"#;
|
||||
|
||||
pub const LIST_READINGS_BY_DEVICE: &str = r#"
|
||||
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
|
||||
FROM readings
|
||||
WHERE device_id=?1 AND timestamp>=?2
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
|
||||
pub const LIST_READINGS_ALL: &str = r#"
|
||||
SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source
|
||||
FROM readings
|
||||
WHERE timestamp>=?1
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT ?2
|
||||
"#;
|
||||
|
||||
pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1";
|
||||
|
||||
pub const INSERT_EVENT: &str =
|
||||
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const LIST_EVENTS: &str =
|
||||
"SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1";
|
||||
|
||||
pub const LIST_API_TOKENS: &str =
|
||||
"SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC";
|
||||
pub const INSERT_API_TOKEN: &str =
|
||||
"INSERT INTO api_tokens(id,name,token_hash,token_prefix,created_at) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1";
|
||||
pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1";
|
||||
|
||||
pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'";
|
||||
pub const UPSERT_RUNTIME_SETTINGS: &str = r#"
|
||||
INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
updated_at=excluded.updated_at
|
||||
"#;
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use chrono::Utc;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: Db,
|
||||
pub settings: Arc<RwLock<RuntimeSettings>>,
|
||||
pub config: Arc<Config>,
|
||||
pub gree: GreeClient,
|
||||
pub events: broadcast::Sender<ApiEvent>,
|
||||
pub http: reqwest::Client,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn broadcast(&self, event: impl Into<String>, data: Value) {
|
||||
let _ = self.events.send(ApiEvent {
|
||||
event: event.into(),
|
||||
timestamp: Utc::now(),
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) {
|
||||
if let Err(err) = self.db.log_event(level, kind, message, &metadata) {
|
||||
tracing::warn!(error=?err, "cannot persist event log");
|
||||
}
|
||||
self.broadcast("log.created", serde_json::json!({
|
||||
"level": level,
|
||||
"kind": kind,
|
||||
"message": message,
|
||||
"metadata": metadata,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user