v0.5.3
This commit is contained in:
+53
-1
@@ -9,7 +9,7 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use chrono::{Duration as ChronoDuration, NaiveTime, Utc};
|
||||
use futures_util::StreamExt;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::Deserialize;
|
||||
@@ -59,6 +59,7 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/history", get(history))
|
||||
.route("/api/control-plan", get(control_plan))
|
||||
.route("/api/events", get(events))
|
||||
.route("/api/events/retention", get(get_event_retention).put(update_event_retention))
|
||||
.route("/api/settings", get(get_settings).put(update_settings))
|
||||
.route("/api/settings/export", get(export_settings))
|
||||
.route("/api/settings/import", post(import_settings))
|
||||
@@ -1060,6 +1061,27 @@ async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>)
|
||||
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EventRetentionInput { days: u32 }
|
||||
|
||||
async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
|
||||
let days = state.settings.read().await.event_log_retention_days;
|
||||
Json(json!({"days": days}))
|
||||
}
|
||||
|
||||
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.event_log_retention_days = input.days.clamp(1, 3650);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
let days = settings.event_log_retention_days;
|
||||
drop(settings);
|
||||
let removed = state.db.prune_events(days as i64)?;
|
||||
state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed}));
|
||||
let public = { let settings = state.settings.read().await; public_settings(&*settings) };
|
||||
state.broadcast("settings.updated", public);
|
||||
Ok(Json(json!({"days": days, "removed": removed})))
|
||||
}
|
||||
|
||||
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
|
||||
let settings = state.settings.read().await;
|
||||
Json(public_settings(&*settings))
|
||||
@@ -1080,6 +1102,9 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
|
||||
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
|
||||
normalize_sensor_aliases(&mut input);
|
||||
validate_night_mode(&mut input)?;
|
||||
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
|
||||
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
|
||||
@@ -1096,6 +1121,27 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
Ok(Json(public_settings(&input)))
|
||||
}
|
||||
|
||||
fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
|
||||
settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases
|
||||
.iter()
|
||||
.filter_map(|(entity, alias)| {
|
||||
let entity = entity.trim();
|
||||
let alias = alias.trim();
|
||||
if entity.is_empty() || alias.is_empty() { return None; }
|
||||
Some((entity.chars().take(160).collect::<String>(), alias.chars().take(80).collect::<String>()))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
|
||||
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
|
||||
NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
|
||||
settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
Ok(Json(state.db.export_configuration(settings)?))
|
||||
@@ -1124,6 +1170,9 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
|
||||
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
|
||||
validate_configuration_export(&export)?;
|
||||
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
|
||||
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
|
||||
normalize_sensor_aliases(&mut export.settings);
|
||||
validate_night_mode(&mut export.settings)?;
|
||||
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
|
||||
state.db.replace_configuration(&export)?;
|
||||
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
|
||||
@@ -1216,8 +1265,10 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
|
||||
"history_retention_days": settings.history_retention_days,
|
||||
"history_compaction_enabled": settings.history_compaction_enabled,
|
||||
"event_log_retention_days": settings.event_log_retention_days,
|
||||
"suppress_device_beep": settings.suppress_device_beep,
|
||||
"debug": settings.debug,
|
||||
"night_mode": settings.night_mode,
|
||||
"influxdb": {
|
||||
"enabled": settings.influxdb.enabled,
|
||||
"version": settings.influxdb.version,
|
||||
@@ -1239,6 +1290,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"default_entity_id": settings.home_assistant.default_entity_id,
|
||||
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
|
||||
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
|
||||
"sensor_aliases": settings.home_assistant.sensor_aliases,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+19
-1
@@ -1,7 +1,7 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, RuntimeSettings};
|
||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, RuntimeSettings};
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(author, version, about)]
|
||||
@@ -54,12 +54,20 @@ impl Config {
|
||||
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
|
||||
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
||||
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
|
||||
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
||||
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
|
||||
influxdb: influx_settings_from_env(),
|
||||
debug: DebugSettings {
|
||||
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
||||
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
||||
},
|
||||
night_mode: NightModeSettings {
|
||||
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
|
||||
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()),
|
||||
end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()),
|
||||
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5),
|
||||
force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true),
|
||||
},
|
||||
home_assistant: HomeAssistantSettings {
|
||||
url: env::var("HA_URL").unwrap_or_default(),
|
||||
token: env::var("HA_TOKEN").unwrap_or_default(),
|
||||
@@ -68,6 +76,7 @@ impl Config {
|
||||
allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false),
|
||||
sensor_aliases: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -78,9 +87,17 @@ impl Config {
|
||||
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; }
|
||||
if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() {
|
||||
settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; }
|
||||
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { if !value.trim().is_empty() { settings.night_mode.start_time = value; } }
|
||||
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } }
|
||||
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; }
|
||||
|
||||
let influx_env_present = [
|
||||
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
|
||||
@@ -114,6 +131,7 @@ fn env_bool(name: &str) -> Option<bool> {
|
||||
}
|
||||
|
||||
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() }
|
||||
fn env_u8(name: &str) -> Option<u8> { env::var(name).ok()?.parse().ok() }
|
||||
|
||||
fn first_env(names: &[&str]) -> Option<String> {
|
||||
names.iter().find_map(|name| {
|
||||
|
||||
@@ -469,6 +469,12 @@ impl Db {
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prune_events(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(queries::PRUNE_EVENTS, [before.to_rfc3339()])? as u64)
|
||||
}
|
||||
|
||||
pub fn list_api_tokens(&self) -> Result<Vec<ApiTokenInfo>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?;
|
||||
@@ -601,6 +607,12 @@ mod tests {
|
||||
assert_eq!(db.list_devices().unwrap().len(), 1);
|
||||
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
{
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap();
|
||||
}
|
||||
assert_eq!(db.prune_events(30).unwrap(), 1);
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
|
||||
let access_token = ApiTokenInfo {
|
||||
id: "token-1".into(),
|
||||
|
||||
+100
-11
@@ -7,7 +7,7 @@ use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -68,6 +68,12 @@ pub fn start(state: AppState) {
|
||||
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
|
||||
}
|
||||
}
|
||||
let event_retention_days = settings.event_log_retention_days.max(1) as i64;
|
||||
match maintenance_state.db.prune_events(event_retention_days) {
|
||||
Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"),
|
||||
Ok(_) => {}
|
||||
Err(err) => tracing::warn!(error=?err, "cannot prune event log"),
|
||||
}
|
||||
sleep(Duration::from_secs(6 * 60 * 60)).await;
|
||||
}
|
||||
});
|
||||
@@ -338,6 +344,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}
|
||||
}
|
||||
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
||||
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||
|
||||
for mut zone in state.db.list_zones()? {
|
||||
if !zone.enabled { continue; }
|
||||
@@ -459,21 +466,36 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
||||
zone.device_setpoint = Some(desired_device_target);
|
||||
|
||||
let desired_fan = if zone.smart_fan {
|
||||
let desired_fan = if night_active {
|
||||
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
|
||||
if zone.smart_fan {
|
||||
Some(night_limited_fan_speed(
|
||||
smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand),
|
||||
max_fan,
|
||||
))
|
||||
} else if device.fan_speed == 0 || device.fan_speed > max_fan {
|
||||
Some(max_fan)
|
||||
} else {
|
||||
Some(device.fan_speed)
|
||||
}
|
||||
} else if zone.smart_fan {
|
||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// When the room becomes satisfied, ask compatible units for Quiet in the same
|
||||
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
|
||||
// only on that transition so a user's manual Quiet choice is not constantly
|
||||
// overwritten while the zone is actively heating/cooling.
|
||||
// on the normal smart-fan transition. When scheduled night mode owns Quiet, it
|
||||
// explicitly enables it inside the window and releases it outside the window.
|
||||
let desired_quiet = smart_quiet_command(
|
||||
zone.smart_fan,
|
||||
state.gree.quiet_command_supported(&device.id),
|
||||
previous_demand,
|
||||
zone.demand,
|
||||
device.quiet,
|
||||
settings.night_mode.enabled,
|
||||
night_active,
|
||||
settings.night_mode.force_quiet,
|
||||
);
|
||||
|
||||
let needs_command = !device.power
|
||||
@@ -505,6 +527,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
"outdoor_temperature": outdoor_temperature,
|
||||
"fan_speed": updated_device.fan_speed,
|
||||
"quiet": updated_device.quiet,
|
||||
"night_mode": night_active,
|
||||
}));
|
||||
}
|
||||
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
||||
@@ -669,13 +692,34 @@ fn smart_quiet_command(
|
||||
previous_demand: bool,
|
||||
demand: bool,
|
||||
device_quiet: bool,
|
||||
night_enabled: bool,
|
||||
night_active: bool,
|
||||
night_force_quiet: bool,
|
||||
) -> Option<bool> {
|
||||
if !smart_fan || !quiet_supported { return None; }
|
||||
if !quiet_supported { return None; }
|
||||
if night_enabled && night_force_quiet {
|
||||
if night_active { return Some(true); }
|
||||
if device_quiet { return Some(false); }
|
||||
}
|
||||
if !smart_fan { return None; }
|
||||
if !demand { return Some(true); }
|
||||
if !previous_demand && device_quiet { return Some(false); }
|
||||
None
|
||||
}
|
||||
|
||||
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
|
||||
let max_fan = max_fan.clamp(1, 5);
|
||||
if requested == 0 { 1 } else { requested.min(max_fan) }
|
||||
}
|
||||
|
||||
pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool {
|
||||
if !settings.enabled { return false; }
|
||||
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; };
|
||||
if start == end { return true; }
|
||||
if start < end { time >= start && time < end } else { time >= start || time < end }
|
||||
}
|
||||
|
||||
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
||||
// When the thermostat is satisfied, keep airflow quiet instead of leaving the
|
||||
// unit in Auto. The caller sends this together with the standby setpoint in
|
||||
@@ -777,8 +821,9 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
let now = Local::now();
|
||||
let night_active = night_mode_active(&settings.night_mode, now.time());
|
||||
let mut zones_out = Vec::new();
|
||||
let mut house_events = Vec::new();
|
||||
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
|
||||
|
||||
for zone in state.db.list_zones()? {
|
||||
let device = devices.iter().find(|item| item.id == zone.device_id);
|
||||
@@ -856,6 +901,10 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
house_mode: settings.house_mode,
|
||||
outdoor_temperature: *state.outdoor_temperature.read().await,
|
||||
control_strategy: settings.control_strategy,
|
||||
night_mode_active: night_active,
|
||||
night_mode_start: settings.night_mode.start_time,
|
||||
night_mode_end: settings.night_mode.end_time,
|
||||
night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5),
|
||||
next_events: house_events,
|
||||
zones: zones_out,
|
||||
rules,
|
||||
@@ -863,6 +912,34 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
}
|
||||
|
||||
|
||||
fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> {
|
||||
if !settings.enabled || limit == 0 { return Vec::new(); }
|
||||
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); };
|
||||
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); };
|
||||
let mut events = Vec::new();
|
||||
for minute in 1..=(48 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let time = candidate.time();
|
||||
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
|
||||
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
|
||||
("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet))
|
||||
} else if time.hour() == end.hour() && time.minute() == end.minute() {
|
||||
("night_mode_end", "Night mode ends".to_string())
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
events.push(ControlPlanEvent {
|
||||
at: candidate.with_timezone(&Utc),
|
||||
kind: kind.into(),
|
||||
label,
|
||||
preset: None,
|
||||
target_temperature: None,
|
||||
});
|
||||
if events.len() >= limit { break; }
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> {
|
||||
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
||||
for minute in 1..=(24 * 60) {
|
||||
@@ -1070,11 +1147,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
|
||||
assert_eq!(smart_quiet_command(true, true, true, false, false), Some(true));
|
||||
assert_eq!(smart_quiet_command(true, true, false, true, true), Some(false));
|
||||
assert_eq!(smart_quiet_command(true, true, true, true, true), None);
|
||||
assert_eq!(smart_quiet_command(true, false, true, false, false), None);
|
||||
assert_eq!(smart_quiet_command(false, true, true, false, false), None);
|
||||
assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true));
|
||||
assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false));
|
||||
assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None);
|
||||
assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None);
|
||||
assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn night_mode_handles_midnight_and_limits_auto_fan() {
|
||||
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true };
|
||||
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
|
||||
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
|
||||
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
|
||||
assert_eq!(night_limited_fan_speed(0, 1), 1);
|
||||
assert_eq!(night_limited_fan_speed(3, 1), 1);
|
||||
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
|
||||
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
@@ -27,9 +28,13 @@ fn default_heat_comfort() -> f64 { 21.0 }
|
||||
fn default_heat_sleep() -> f64 { 19.0 }
|
||||
fn default_heat_away() -> f64 { 17.0 }
|
||||
fn default_history_retention_days() -> u32 { 30 }
|
||||
fn default_event_log_retention_days() -> u32 { 30 }
|
||||
fn default_influx_version() -> String { "2".into() }
|
||||
fn default_influx_database() -> String { "gree_controller".into() }
|
||||
fn default_influx_threshold_days() -> u32 { 30 }
|
||||
fn default_night_start() -> String { "22:00".into() }
|
||||
fn default_night_end() -> String { "06:00".into() }
|
||||
fn default_night_max_fan_speed() -> u8 { 1 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
@@ -409,6 +414,9 @@ pub struct HomeAssistantSettings {
|
||||
/// Accept self-signed/expired certificates for local Home Assistant HTTPS.
|
||||
#[serde(default)]
|
||||
pub allow_invalid_tls: bool,
|
||||
/// Friendly labels used only by the controller UI/charts; entity_id remains the storage key.
|
||||
#[serde(default)]
|
||||
pub sensor_aliases: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -465,6 +473,34 @@ pub struct DebugSettings {
|
||||
pub gree_frames: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NightModeSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_night_start")]
|
||||
pub start_time: String,
|
||||
#[serde(default = "default_night_end")]
|
||||
pub end_time: String,
|
||||
/// Maximum fan speed used by thermostat control during night hours (1=Low..5=High).
|
||||
#[serde(default = "default_night_max_fan_speed")]
|
||||
pub max_fan_speed: u8,
|
||||
/// Ask compatible GREE units to keep Quiet enabled during the whole night window.
|
||||
#[serde(default = "default_true")]
|
||||
pub force_quiet: bool,
|
||||
}
|
||||
|
||||
impl Default for NightModeSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
start_time: default_night_start(),
|
||||
end_time: default_night_end(),
|
||||
max_fan_speed: default_night_max_fan_speed(),
|
||||
force_quiet: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigurationExport {
|
||||
pub format_version: u32,
|
||||
@@ -528,6 +564,10 @@ pub struct ControlPlan {
|
||||
pub house_mode: String,
|
||||
pub outdoor_temperature: Option<f64>,
|
||||
pub control_strategy: String,
|
||||
pub night_mode_active: bool,
|
||||
pub night_mode_start: String,
|
||||
pub night_mode_end: String,
|
||||
pub night_mode_max_fan_speed: u8,
|
||||
pub next_events: Vec<ControlPlanEvent>,
|
||||
pub zones: Vec<ZoneControlPlan>,
|
||||
pub rules: Vec<AutomationPlanRule>,
|
||||
@@ -554,6 +594,9 @@ pub struct RuntimeSettings {
|
||||
pub history_retention_days: u32,
|
||||
#[serde(default = "default_true")]
|
||||
pub history_compaction_enabled: bool,
|
||||
/// Retention window for controller event/debug log rows.
|
||||
#[serde(default = "default_event_log_retention_days")]
|
||||
pub event_log_retention_days: u32,
|
||||
/// Add protocol-specific buzzer suppression fields to command frames.
|
||||
#[serde(default)]
|
||||
pub suppress_device_beep: bool,
|
||||
@@ -561,6 +604,8 @@ pub struct RuntimeSettings {
|
||||
pub influxdb: InfluxDbSettings,
|
||||
#[serde(default)]
|
||||
pub debug: DebugSettings,
|
||||
#[serde(default)]
|
||||
pub night_mode: NightModeSettings,
|
||||
pub home_assistant: HomeAssistantSettings,
|
||||
}
|
||||
|
||||
|
||||
@@ -382,6 +382,7 @@ pub const INSERT_EVENT: &str =
|
||||
"INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)";
|
||||
pub const LIST_EVENTS: &str =
|
||||
"SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1";
|
||||
pub const PRUNE_EVENTS: &str = "DELETE FROM event_log WHERE timestamp < ?1";
|
||||
|
||||
pub const LIST_API_TOKENS: &str =
|
||||
"SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC";
|
||||
|
||||
Reference in New Issue
Block a user