v0.4.1
This commit is contained in:
+184
-22
@@ -47,6 +47,9 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/zones", get(list_zones).post(create_zone))
|
||||
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
|
||||
.route("/api/zones/:id/control", post(update_zone_control))
|
||||
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
|
||||
.route("/api/house/control", post(update_house_control))
|
||||
.route("/api/house/preset", post(update_house_preset))
|
||||
.route("/api/schedules", get(list_schedules).post(create_schedule))
|
||||
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
|
||||
.route("/api/automations", get(list_automations).post(create_automation))
|
||||
@@ -155,6 +158,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
"automations": state.db.list_automations()?,
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
"settings": public_settings(&settings),
|
||||
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
||||
"system": {
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
@@ -324,14 +328,34 @@ struct ZoneInput {
|
||||
enabled: bool,
|
||||
#[serde(default = "cool")]
|
||||
mode: String,
|
||||
#[serde(default = "yes")]
|
||||
inherit_house_mode: bool,
|
||||
#[serde(default = "setpoint")]
|
||||
setpoint: f64,
|
||||
#[serde(default = "cool_comfort")]
|
||||
cool_comfort_setpoint: f64,
|
||||
#[serde(default = "cool_sleep")]
|
||||
cool_sleep_setpoint: f64,
|
||||
#[serde(default = "cool_away")]
|
||||
cool_away_setpoint: f64,
|
||||
#[serde(default = "heat_comfort")]
|
||||
heat_comfort_setpoint: f64,
|
||||
#[serde(default = "heat_sleep")]
|
||||
heat_sleep_setpoint: f64,
|
||||
#[serde(default = "heat_away")]
|
||||
heat_away_setpoint: f64,
|
||||
#[serde(default = "hysteresis")]
|
||||
hysteresis: f64,
|
||||
#[serde(default = "cycle")]
|
||||
min_on_seconds: u64,
|
||||
#[serde(default = "cycle")]
|
||||
min_off_seconds: u64,
|
||||
#[serde(default = "min_adjust")]
|
||||
min_adjust_seconds: u64,
|
||||
#[serde(default = "standby_offset")]
|
||||
standby_offset_c: f64,
|
||||
#[serde(default = "yes")]
|
||||
smart_fan: bool,
|
||||
#[serde(default = "device_source")]
|
||||
sensor_source: String,
|
||||
#[serde(default)]
|
||||
@@ -344,8 +368,16 @@ struct ZoneInput {
|
||||
fn yes() -> bool { true }
|
||||
fn cool() -> String { "cool".into() }
|
||||
fn setpoint() -> f64 { 24.0 }
|
||||
fn cool_comfort() -> f64 { 23.0 }
|
||||
fn cool_sleep() -> f64 { 24.5 }
|
||||
fn cool_away() -> f64 { 27.0 }
|
||||
fn heat_comfort() -> f64 { 21.0 }
|
||||
fn heat_sleep() -> f64 { 19.0 }
|
||||
fn heat_away() -> f64 { 17.0 }
|
||||
fn hysteresis() -> f64 { 0.6 }
|
||||
fn cycle() -> u64 { 180 }
|
||||
fn min_adjust() -> u64 { 120 }
|
||||
fn standby_offset() -> f64 { 2.0 }
|
||||
fn external_sensor_weight() -> f64 { 0.4 }
|
||||
fn max_sensor_difference() -> f64 { 3.0 }
|
||||
fn device_source() -> String { "device".into() }
|
||||
@@ -353,8 +385,12 @@ 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..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
||||
for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint,
|
||||
self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); }
|
||||
}
|
||||
if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); }
|
||||
if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); }
|
||||
if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
|
||||
if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); }
|
||||
if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); }
|
||||
@@ -367,11 +403,17 @@ impl ZoneInput {
|
||||
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,
|
||||
mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1,
|
||||
cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint,
|
||||
cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint,
|
||||
heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint,
|
||||
hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds,
|
||||
min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan,
|
||||
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
|
||||
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference,
|
||||
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
|
||||
active_preset: "comfort".into(), manual_preset: None, manual_override_until: None,
|
||||
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
|
||||
demand: false, last_action_at: None,
|
||||
created_at, updated_at: Utc::now(),
|
||||
}
|
||||
@@ -399,6 +441,12 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
zone.external_temperature = existing.external_temperature;
|
||||
zone.current_temperature = existing.current_temperature;
|
||||
zone.control_temperature_source = existing.control_temperature_source;
|
||||
zone.active_preset = existing.active_preset;
|
||||
zone.manual_preset = existing.manual_preset;
|
||||
zone.manual_override_until = existing.manual_override_until;
|
||||
zone.effective_mode = existing.effective_mode;
|
||||
zone.effective_setpoint = existing.effective_setpoint;
|
||||
zone.device_setpoint = existing.device_setpoint;
|
||||
zone.demand = existing.demand;
|
||||
zone.last_action_at = existing.last_action_at;
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -407,36 +455,139 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
|
||||
}
|
||||
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
||||
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let schedules = state.db.list_schedules()?;
|
||||
|
||||
if let Some(value) = patch.setpoint {
|
||||
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); }
|
||||
zone.setpoint = (value * 2.0).round() / 2.0;
|
||||
zone.manual_preset = Some("custom".into());
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
if let Some(value) = patch.mode.as_deref() {
|
||||
if !matches!(value, "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); }
|
||||
zone.mode = value.to_string();
|
||||
match value {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
zone.inherit_house_mode = false;
|
||||
zone.mode = value.to_string();
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())),
|
||||
}
|
||||
}
|
||||
if let Some(value) = patch.preset.as_deref() {
|
||||
match value {
|
||||
"auto" => {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
"comfort" | "sleep" | "away" | "custom" => {
|
||||
zone.manual_preset = Some(value.to_string());
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported zone preset".into())),
|
||||
}
|
||||
}
|
||||
if patch.clear_override.unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
if let Some(value) = patch.enabled { zone.enabled = value; }
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
|
||||
// Quick zone controls also update the paired climate unit immediately. Power is
|
||||
// intentionally left unchanged; the zone engine still owns ON/OFF demand.
|
||||
if patch.setpoint.is_some() || patch.mode.is_some() {
|
||||
let command = DeviceCommand {
|
||||
mode: patch.mode.as_ref().map(|_| zone.mode.clone()),
|
||||
target_temperature: patch.setpoint.map(|_| zone.setpoint),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(err) = engine::send_command(&state, &zone.device_id, command).await {
|
||||
state.log("warn", "zone.quick_control_device_error", &format!("{}: {err}", zone.name), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||
}
|
||||
}
|
||||
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode, "enabled": zone.enabled}));
|
||||
state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset,
|
||||
"override_until": zone.manual_override_until, "enabled": zone.enabled
|
||||
}));
|
||||
Ok(Json(zone))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
|
||||
}
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.house_mode = input.mode;
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
let payload = public_settings(&settings);
|
||||
state.broadcast("settings.updated", payload.clone());
|
||||
state.log("info", "house.mode", &format!("House mode set to {}", settings.house_mode), json!({"mode": settings.house_mode}));
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HousePresetPatch { preset: String }
|
||||
|
||||
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
|
||||
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
|
||||
}
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let mut zones = state.db.list_zones()?;
|
||||
for zone in &mut zones {
|
||||
if input.preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
} else {
|
||||
zone.manual_preset = Some(input.preset.clone());
|
||||
zone.manual_override_until = Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()));
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
}
|
||||
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({"preset": input.preset}));
|
||||
Ok(Json(json!({"preset": input.preset, "zones": zones})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScheduleTemplateRequest { template: String }
|
||||
|
||||
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> {
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut items: Vec<Schedule> = Vec::new();
|
||||
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
||||
items.push(Schedule {
|
||||
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
|
||||
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
|
||||
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
});
|
||||
};
|
||||
let all = vec![1,2,3,4,5,6,7];
|
||||
match input.template.as_str() {
|
||||
"family" => {
|
||||
add("Comfort", all.clone(), "06:30", "22:30", "comfort");
|
||||
add("Sleep", all, "22:30", "06:30", "sleep");
|
||||
}
|
||||
"child" => {
|
||||
add("Comfort", all.clone(), "06:30", "20:30", "comfort");
|
||||
add("Sleep", all, "20:30", "06:30", "sleep");
|
||||
}
|
||||
"bedroom" => {
|
||||
add("Comfort", all.clone(), "06:30", "22:00", "comfort");
|
||||
add("Sleep", all, "22:00", "06:30", "sleep");
|
||||
}
|
||||
"workday" => {
|
||||
let weekdays = vec![1,2,3,4,5];
|
||||
let weekend = vec![6,7];
|
||||
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
|
||||
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
||||
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
||||
add("Sleep", weekdays, "22:30", "06:30", "sleep");
|
||||
add("Weekend", weekend.clone(), "08:00", "23:00", "comfort");
|
||||
add("Weekend sleep", weekend, "23:00", "08:00", "sleep");
|
||||
}
|
||||
"always" => add("Comfort", all, "00:00", "23:59", "comfort"),
|
||||
_ => return Err(AppError::BadRequest("unknown schedule template".into())),
|
||||
}
|
||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||
}
|
||||
|
||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
state.broadcast("zone.deleted", json!({"id": id}));
|
||||
@@ -452,21 +603,25 @@ struct ScheduleInput {
|
||||
weekdays: Vec<u32>,
|
||||
start_time: String,
|
||||
end_time: String,
|
||||
#[serde(default = "schedule_preset")]
|
||||
preset: String,
|
||||
setpoint: f64,
|
||||
}
|
||||
fn schedule_preset() -> String { "custom".into() }
|
||||
impl ScheduleInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); }
|
||||
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); }
|
||||
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?;
|
||||
if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
|
||||
if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); }
|
||||
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
|
||||
Ok(())
|
||||
}
|
||||
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
|
||||
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
|
||||
setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
||||
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now() }
|
||||
}
|
||||
}
|
||||
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
|
||||
@@ -591,6 +746,8 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
|
||||
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
|
||||
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
|
||||
if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
|
||||
if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); }
|
||||
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|
||||
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
|
||||
input.discovery_broadcast.parse::<std::net::SocketAddr>()
|
||||
@@ -674,11 +831,16 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"zone_interval_seconds": settings.zone_interval_seconds,
|
||||
"discovery_timeout_ms": settings.discovery_timeout_ms,
|
||||
"discovery_broadcast": settings.discovery_broadcast,
|
||||
"house_mode": settings.house_mode,
|
||||
"control_strategy": settings.control_strategy,
|
||||
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
|
||||
"home_assistant": {
|
||||
"url": settings.home_assistant.url,
|
||||
"token": "",
|
||||
"token_configured": !settings.home_assistant.token.trim().is_empty(),
|
||||
"default_entity_id": settings.home_assistant.default_entity_id,
|
||||
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
|
||||
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,10 +49,19 @@ impl Config {
|
||||
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(),
|
||||
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
|
||||
control_strategy: "setpoint".into(),
|
||||
outdoor_assist_enabled: env::var("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(true),
|
||||
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(),
|
||||
outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(),
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,21 @@ impl Db {
|
||||
self.delete_by_id("schedules", id)
|
||||
}
|
||||
|
||||
pub fn replace_schedules_for_zone(&self, zone_id: &str, schedules: &[Schedule]) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [zone_id])?;
|
||||
for schedule in schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save_automation(&self, item: &Automation) -> Result<()> {
|
||||
let payload = Self::to_json(item)?;
|
||||
let conn = self.lock()?;
|
||||
|
||||
+255
-34
@@ -248,15 +248,54 @@ fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
||||
async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
|
||||
// Outdoor temperature is deliberately optional. It never replaces the room sensor;
|
||||
// it only makes the active setpoint/fan a little more assertive in extreme weather.
|
||||
let outdoor_temperature = if settings.outdoor_assist_enabled && !settings.home_assistant.outdoor_entity_id.trim().is_empty() {
|
||||
match home_assistant::read_temperature(
|
||||
&state.http,
|
||||
&settings.home_assistant,
|
||||
Some(settings.home_assistant.outdoor_entity_id.trim()),
|
||||
).await {
|
||||
Ok(value) => Some(value),
|
||||
Err(err) => {
|
||||
tracing::debug!(error=?err, "outdoor Home Assistant sensor unavailable");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut current = state.outdoor_temperature.write().await;
|
||||
if *current != outdoor_temperature {
|
||||
*current = outdoor_temperature;
|
||||
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_override_until = None;
|
||||
}
|
||||
|
||||
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 effective_mode = if settings.house_mode == "off" {
|
||||
"off"
|
||||
} else if zone.inherit_house_mode {
|
||||
settings.house_mode.as_str()
|
||||
} else {
|
||||
zone.mode.as_str()
|
||||
};
|
||||
zone.effective_mode = effective_mode.to_string();
|
||||
|
||||
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") {
|
||||
@@ -290,33 +329,99 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}));
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
// Global Off is the only normal path that intentionally powers units down.
|
||||
if effective_mode == "off" {
|
||||
zone.active_preset = "off".into();
|
||||
zone.effective_setpoint = None;
|
||||
zone.device_setpoint = None;
|
||||
zone.demand = false;
|
||||
if device.power {
|
||||
match send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
Ok(_) => zone.last_action_at = Some(Utc::now()),
|
||||
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)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
|
||||
zone.active_preset = preset;
|
||||
zone.effective_setpoint = Some(target);
|
||||
|
||||
let Some(temp) = temperature else {
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
continue;
|
||||
};
|
||||
|
||||
let half = zone.hysteresis.max(0.1) / 2.0;
|
||||
zone.demand = match effective_mode {
|
||||
"heat" => {
|
||||
if temp <= target - half { true }
|
||||
else if temp >= target + half { false }
|
||||
else { zone.demand }
|
||||
}
|
||||
_ => {
|
||||
if temp >= target + half { true }
|
||||
else if temp <= target - half { false }
|
||||
else { zone.demand }
|
||||
}
|
||||
};
|
||||
|
||||
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
||||
// stop naturally when we move the target to the satisfied side of room temperature.
|
||||
let assist = outdoor_assist_offset(effective_mode, outdoor_temperature, temp, target);
|
||||
let active_target = match effective_mode {
|
||||
"heat" => target + assist,
|
||||
_ => target - assist,
|
||||
};
|
||||
let standby_target = match effective_mode {
|
||||
"heat" => target - zone.standby_offset_c.max(0.5),
|
||||
_ => target + zone.standby_offset_c.max(0.5),
|
||||
};
|
||||
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
||||
zone.device_setpoint = Some(desired_device_target);
|
||||
|
||||
let desired_fan = if zone.smart_fan {
|
||||
Some(smart_fan_speed(effective_mode, temp, target, outdoor_temperature, zone.demand))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let needs_command = !device.power
|
||||
|| device.mode != effective_mode
|
||||
|| (device.target_temperature - desired_device_target).abs() >= 0.5
|
||||
|| desired_fan.map(|fan| fan != device.fan_speed).unwrap_or(false);
|
||||
|
||||
let urgent_mode_change = !device.power || device.mode != effective_mode;
|
||||
if needs_command && (urgent_mode_change || adjustment_allowed(&zone)) {
|
||||
let command = DeviceCommand {
|
||||
power: Some(true),
|
||||
mode: Some(effective_mode.to_string()),
|
||||
target_temperature: Some(desired_device_target),
|
||||
fan_speed: desired_fan,
|
||||
..Default::default()
|
||||
};
|
||||
match send_command(state, &zone.device_id, command).await {
|
||||
Ok(_) => {
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
||||
"zone_id": zone.id,
|
||||
"room_temperature": temp,
|
||||
"comfort_target": target,
|
||||
"device_target": desired_device_target,
|
||||
"mode": effective_mode,
|
||||
"preset": zone.active_preset,
|
||||
"outdoor_temperature": outdoor_temperature,
|
||||
}));
|
||||
}
|
||||
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)?);
|
||||
}
|
||||
@@ -351,17 +456,94 @@ fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, externa
|
||||
}
|
||||
}
|
||||
|
||||
fn cycle_allowed(zone: &Zone, currently_on: bool) -> bool {
|
||||
fn adjustment_allowed(zone: &Zone) -> 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 }
|
||||
(Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15)
|
||||
}
|
||||
|
||||
fn active_setpoint(zone: &Zone, schedules: &[Schedule], now: DateTime<Local>) -> Option<f64> {
|
||||
fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 {
|
||||
let value = value.clamp(16.0, 30.0);
|
||||
match (mode, demand) {
|
||||
("heat", true) => value.ceil(),
|
||||
("heat", false) => value.floor(),
|
||||
(_, true) => value.floor(),
|
||||
(_, false) => value.ceil(),
|
||||
}
|
||||
}
|
||||
|
||||
fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f64) -> f64 {
|
||||
let Some(outdoor) = outdoor else { return 0.0; };
|
||||
let room_error = (room - target).abs();
|
||||
let weather = match mode {
|
||||
"heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0),
|
||||
_ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0),
|
||||
};
|
||||
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
||||
if !demand { return 0; }
|
||||
let error = (room - target).abs();
|
||||
let extreme_weather = match (mode, outdoor) {
|
||||
("heat", Some(value)) => value <= 0.0,
|
||||
(_, Some(value)) => value >= 32.0,
|
||||
_ => false,
|
||||
};
|
||||
if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 }
|
||||
}
|
||||
|
||||
fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
|
||||
if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; }
|
||||
match (mode, preset) {
|
||||
("heat", "sleep") => zone.heat_sleep_setpoint,
|
||||
("heat", "away") => zone.heat_away_setpoint,
|
||||
("heat", _) => zone.heat_comfort_setpoint,
|
||||
(_, "sleep") => zone.cool_sleep_setpoint,
|
||||
(_, "away") => zone.cool_away_setpoint,
|
||||
(_, _) => zone.cool_comfort_setpoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) {
|
||||
if let Some(manual) = zone.manual_preset.as_deref() {
|
||||
return if manual == "custom" {
|
||||
("custom".into(), zone.setpoint)
|
||||
} else {
|
||||
(manual.to_string(), profile_setpoint(zone, manual, mode))
|
||||
};
|
||||
}
|
||||
if let Some(item) = schedule {
|
||||
return if item.preset == "custom" {
|
||||
("custom".into(), item.setpoint)
|
||||
} else {
|
||||
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
|
||||
};
|
||||
}
|
||||
("comfort".into(), profile_setpoint(zone, "comfort", mode))
|
||||
}
|
||||
|
||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
.last()
|
||||
.map(|item| item.setpoint)
|
||||
}
|
||||
|
||||
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> DateTime<Utc> {
|
||||
let current = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
||||
.last()
|
||||
.map(|item| item.id.as_str());
|
||||
for minute in 1..=(48 * 60) {
|
||||
let candidate = now + chrono::Duration::minutes(minute);
|
||||
let next = schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
|
||||
.last()
|
||||
.map(|item| item.id.as_str());
|
||||
if next != current {
|
||||
return candidate.with_timezone(&Utc);
|
||||
}
|
||||
}
|
||||
(now + chrono::Duration::hours(8)).with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
||||
@@ -440,7 +622,7 @@ mod tests {
|
||||
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,
|
||||
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0,
|
||||
created_at: Utc::now(), updated_at: Utc::now(),
|
||||
};
|
||||
assert!(schedule_active(&item, now));
|
||||
@@ -449,11 +631,15 @@ mod tests {
|
||||
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,
|
||||
mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1,
|
||||
cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0,
|
||||
heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0,
|
||||
hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true,
|
||||
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
|
||||
external_sensor_weight: 0.4, max_sensor_difference: 3.0, 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(),
|
||||
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
|
||||
manual_preset: None, manual_override_until: None, effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
|
||||
demand: false, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,4 +670,39 @@ mod tests {
|
||||
assert!(!discrepancy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seasonal_profiles_resolve_independently() {
|
||||
let zone = test_zone("device");
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0);
|
||||
assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5);
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0);
|
||||
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_zone_keeps_old_comfort_setpoint() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.profile_version = 0;
|
||||
zone.setpoint = 22.5;
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5);
|
||||
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_setpoint_rounding_preserves_control_direction() {
|
||||
assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0);
|
||||
assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0);
|
||||
assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0);
|
||||
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdoor_assist_is_bounded_and_direction_neutral() {
|
||||
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
|
||||
let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0);
|
||||
assert!(cool > 0.0 && cool <= 1.0);
|
||||
assert!(heat > 0.0 && heat <= 1.0);
|
||||
assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+16
-1
@@ -1,10 +1,24 @@
|
||||
use std::time::Duration;
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde_json::Value;
|
||||
use url::Url;
|
||||
use crate::models::HomeAssistantSettings;
|
||||
|
||||
fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSettings) -> Result<reqwest::Client> {
|
||||
if !settings.allow_invalid_tls {
|
||||
return Ok(default_client.clone());
|
||||
}
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION")))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.context("cannot build Home Assistant HTTPS client")
|
||||
}
|
||||
|
||||
pub async fn read_temperature(
|
||||
client: &reqwest::Client,
|
||||
default_client: &reqwest::Client,
|
||||
settings: &HomeAssistantSettings,
|
||||
entity_override: Option<&str>,
|
||||
) -> Result<f64> {
|
||||
@@ -19,6 +33,7 @@ pub async fn read_temperature(
|
||||
let path = format!("api/states/{}", entity.trim_start_matches('/'));
|
||||
base = base.join(&path).context("cannot build Home Assistant API URL")?;
|
||||
|
||||
let client = request_client(default_client, settings)?;
|
||||
let response = client.get(base)
|
||||
.bearer_auth(settings.token.trim())
|
||||
.header("Accept", "application/json")
|
||||
|
||||
@@ -58,6 +58,7 @@ async fn main() -> Result<()> {
|
||||
),
|
||||
events,
|
||||
http,
|
||||
outdoor_temperature: Arc::new(RwLock::new(None)),
|
||||
started: Instant::now(),
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,18 @@ 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 }
|
||||
fn default_house_mode() -> String { "cool".into() }
|
||||
fn default_control_strategy() -> String { "setpoint".into() }
|
||||
fn default_standby_offset() -> f64 { 2.0 }
|
||||
fn default_min_adjust() -> u64 { 120 }
|
||||
fn default_schedule_preset() -> String { "custom".into() }
|
||||
fn default_active_preset() -> String { "comfort".into() }
|
||||
fn default_cool_comfort() -> f64 { 23.0 }
|
||||
fn default_cool_sleep() -> f64 { 24.5 }
|
||||
fn default_cool_away() -> f64 { 27.0 }
|
||||
fn default_heat_comfort() -> f64 { 21.0 }
|
||||
fn default_heat_sleep() -> f64 { 19.0 }
|
||||
fn default_heat_away() -> f64 { 17.0 }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Device {
|
||||
@@ -155,14 +167,42 @@ pub struct Zone {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: String,
|
||||
/// When true, the zone follows the global house heating/cooling mode.
|
||||
#[serde(default = "default_true")]
|
||||
pub inherit_house_mode: bool,
|
||||
/// Manual/custom target retained for quick thermostat overrides.
|
||||
#[serde(default = "default_target")]
|
||||
pub setpoint: f64,
|
||||
/// 0 means a zone created before smart profiles; its legacy setpoint remains the comfort target until edited.
|
||||
#[serde(default)]
|
||||
pub profile_version: u8,
|
||||
#[serde(default = "default_cool_comfort")]
|
||||
pub cool_comfort_setpoint: f64,
|
||||
#[serde(default = "default_cool_sleep")]
|
||||
pub cool_sleep_setpoint: f64,
|
||||
#[serde(default = "default_cool_away")]
|
||||
pub cool_away_setpoint: f64,
|
||||
#[serde(default = "default_heat_comfort")]
|
||||
pub heat_comfort_setpoint: f64,
|
||||
#[serde(default = "default_heat_sleep")]
|
||||
pub heat_sleep_setpoint: f64,
|
||||
#[serde(default = "default_heat_away")]
|
||||
pub heat_away_setpoint: f64,
|
||||
#[serde(default = "default_hysteresis")]
|
||||
pub hysteresis: f64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_on_seconds: u64,
|
||||
#[serde(default = "default_min_cycle")]
|
||||
pub min_off_seconds: u64,
|
||||
/// Minimum interval between automatic setpoint/fan adjustments.
|
||||
#[serde(default = "default_min_adjust")]
|
||||
pub min_adjust_seconds: u64,
|
||||
/// Difference applied to the AC setpoint while the room is satisfied.
|
||||
#[serde(default = "default_standby_offset")]
|
||||
pub standby_offset_c: f64,
|
||||
/// Let the controller adjust fan speed based on demand and outdoor conditions.
|
||||
#[serde(default = "default_true")]
|
||||
pub smart_fan: bool,
|
||||
#[serde(default = "default_sensor_source")]
|
||||
pub sensor_source: String,
|
||||
#[serde(default)]
|
||||
@@ -185,6 +225,20 @@ pub struct Zone {
|
||||
/// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`.
|
||||
#[serde(default = "default_control_temperature_source")]
|
||||
pub control_temperature_source: String,
|
||||
/// Effective profile currently used by the zone: comfort/sleep/away/custom.
|
||||
#[serde(default = "default_active_preset")]
|
||||
pub active_preset: String,
|
||||
/// Optional user override. Cleared automatically at the next schedule boundary.
|
||||
#[serde(default)]
|
||||
pub manual_preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub manual_override_until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub effective_mode: String,
|
||||
#[serde(default)]
|
||||
pub effective_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub device_setpoint: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub demand: bool,
|
||||
#[serde(default)]
|
||||
@@ -204,6 +258,11 @@ pub struct ZoneControlPatch {
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
/// `auto` clears the override; comfort/sleep/away/custom create a temporary override.
|
||||
#[serde(default)]
|
||||
pub preset: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clear_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -219,6 +278,9 @@ pub struct Schedule {
|
||||
pub start_time: String,
|
||||
/// Local time HH:MM. Ranges crossing midnight are supported.
|
||||
pub end_time: String,
|
||||
/// comfort/sleep/away/custom. Non-custom profiles resolve their target from the zone.
|
||||
#[serde(default = "default_schedule_preset")]
|
||||
pub preset: String,
|
||||
pub setpoint: f64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -279,6 +341,12 @@ pub struct HomeAssistantSettings {
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub default_entity_id: String,
|
||||
/// Optional outdoor temperature sensor used only as an assist signal.
|
||||
#[serde(default)]
|
||||
pub outdoor_entity_id: String,
|
||||
/// Accept self-signed/expired certificates for local Home Assistant HTTPS.
|
||||
#[serde(default)]
|
||||
pub allow_invalid_tls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -289,6 +357,14 @@ pub struct RuntimeSettings {
|
||||
pub zone_interval_seconds: u64,
|
||||
pub discovery_timeout_ms: u64,
|
||||
pub discovery_broadcast: String,
|
||||
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off.
|
||||
#[serde(default = "default_house_mode")]
|
||||
pub house_mode: String,
|
||||
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
|
||||
#[serde(default = "default_control_strategy")]
|
||||
pub control_strategy: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub outdoor_assist_enabled: bool,
|
||||
pub home_assistant: HomeAssistantSettings,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct AppState {
|
||||
pub gree: GreeClient,
|
||||
pub events: broadcast::Sender<ApiEvent>,
|
||||
pub http: reqwest::Client,
|
||||
pub outdoor_temperature: Arc<RwLock<Option<f64>>>,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user