This commit is contained in:
Mateusz Gruszczyński
2026-09-04 11:00:15 +02:00
parent 8152e63d63
commit 236540e0f0
27 changed files with 557 additions and 102 deletions
+2 -1
View File
@@ -278,7 +278,7 @@ async fn list_home_assistant_groups(
let groups = state.db.list_groups()?;
let zones = state.db.list_zones()?;
let devices = state.db.list_devices()?;
let plan = engine::build_control_plan(&state).await?;
let plan = engine::get_control_plan_snapshot(&state).await?;
let settings = state.settings.read().await.clone();
let mut output = Vec::with_capacity(groups.len());
@@ -288,6 +288,7 @@ async fn list_home_assistant_groups(
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
.collect::<Vec<_>>();
let planned_members = plan
.plan
.zones
.iter()
.filter(|zone| {
+2 -3
View File
@@ -485,7 +485,6 @@ async fn history(
}
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
Ok(Json(serde_json::to_value(
engine::build_control_plan(&state).await?,
)?))
let snapshot = engine::get_control_plan_snapshot(&state).await?;
Ok(Json(serde_json::to_value(snapshot.plan.as_ref())?))
}
+13
View File
@@ -1,3 +1,16 @@
async fn home_assistant_snapshot(
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
let control_plan = engine::get_control_plan_snapshot(&state).await?;
let groups = list_home_assistant_groups(State(state.clone())).await?.0;
Ok(Json(json!({
"devices": state.db.list_devices()?,
"control_plan": control_plan.plan.as_ref(),
"control_plan_revision": control_plan.revision,
"groups": groups,
})))
}
#[derive(Debug, Deserialize)]
struct HaTestRequest {
entity_id: Option<String>,
+3
View File
@@ -20,6 +20,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
let online_count = devices.iter().filter(|value| value.online).count();
let simulator_count = devices.iter().filter(|value| value.simulated).count();
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
let control_plan = engine::get_control_plan_snapshot(state).await?;
Ok(json!({
"devices": devices,
"zones": state.db.list_zones()?,
@@ -30,6 +31,8 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
"access_tokens": state.db.list_api_tokens()?,
"house": {"mode": settings.house_mode},
"outdoor_temperature": *state.outdoor_temperature.read().await,
"control_plan": control_plan.plan.as_ref(),
"control_plan_revision": control_plan.revision,
"system": {
"version": env!("CARGO_PKG_VERSION"),
"uptime_seconds": state.started.elapsed().as_secs(),
+72 -12
View File
@@ -14,21 +14,60 @@ async fn websocket(
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()}})
fn control_plan_ws_message(snapshot: &crate::state::ControlPlanSnapshot) -> Value {
json!({
"event": "control_plan.updated",
"timestamp": Utc::now(),
"data": {
"revision": snapshot.revision,
"plan": snapshot.plan.as_ref(),
}
})
}
async fn send_ws_bootstrap(state: &AppState, socket: &mut WebSocket) -> Result<Option<u64>, ()> {
let (message, revision) = match build_bootstrap(state).await {
Ok(data) => {
let revision = data.get("control_plan_revision").and_then(Value::as_u64);
(
json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
revision,
)
}
Err(err) => (
json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}),
None,
),
};
if socket
.send(Message::Text(initial.to_string()))
socket
.send(Message::Text(message.to_string()))
.await
.is_err()
{
return;
}
.map_err(|_| ())?;
Ok(revision)
}
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
// Subscribe before building the bootstrap so state changes during bootstrap generation are
// queued and can be applied immediately after the first frame.
let mut receiver = state.events.subscribe();
let mut control_plan = state.subscribe_control_plan();
let bootstrap_revision = match send_ws_bootstrap(&state, &mut socket).await {
Ok(revision) => revision,
Err(()) => return,
};
let mut last_control_plan_revision = bootstrap_revision;
// If the watch value is exactly the plan embedded in bootstrap, mark it seen to avoid a
// duplicate control_plan.updated frame. A newer revision remains pending and is sent below.
let current_revision = control_plan
.borrow()
.as_ref()
.map(|snapshot| snapshot.revision);
if current_revision == bootstrap_revision {
control_plan.borrow_and_update();
}
loop {
tokio::select! {
event = receiver.recv() => {
@@ -38,10 +77,31 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
if socket.send(Message::Text(text)).await.is_err() { break; }
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(skipped, "websocket client lagged; sending full bootstrap resync");
// Drop the retained stale backlog before taking the replacement snapshot.
// Events created while bootstrap is built are queued on this fresh receiver.
receiver = state.events.subscribe();
match send_ws_bootstrap(&state, &mut socket).await {
Ok(revision) => last_control_plan_revision = revision,
Err(()) => break,
}
}
Err(_) => break,
}
}
changed = control_plan.changed() => {
if changed.is_err() { break; }
let snapshot = control_plan.borrow_and_update().clone();
if let Some(snapshot) = snapshot {
if last_control_plan_revision == Some(snapshot.revision) {
continue;
}
let text = control_plan_ws_message(snapshot.as_ref()).to_string();
if socket.send(Message::Text(text)).await.is_err() { break; }
last_control_plan_revision = Some(snapshot.revision);
}
}
message = socket.next() => {
match message {
Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } }