From cd2442f67222170dc11040808befd62fca8d1c68 Mon Sep 17 00:00:00 2001 From: Alexander Date: Thu, 25 Jun 2026 15:36:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(v0.4):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D0=BA=D0=B0=20thermal+coprocessor=20=D1=86=D0=B8=D0=BA?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=20+=20D-Bus=20ThermalState/ThermalChanged=20?= =?UTF-8?q?+=20dev-mock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawn_loops (thermal-монитор + coprocessor heartbeat/wait/safe-to-cut/B09) в main после регистрации интерфейса; PowerService += thermal_state + хендлы fsm/thermal_state + mock(temp,copro); dev-mock += SetTemp/HangSoc/McuLinkLoss; proxy.rs += thermal_state/thermal_changed. Existing integration-тесты подогнаны под mock(temp,copro). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Alexander --- crates/core/shturman-power/src/main.rs | 35 ++++- crates/core/shturman-power/src/service.rs | 143 +++++++++++++++++- .../core/shturman-power/tests/integration.rs | 7 +- crates/shturman-ipc/src/proxy.rs | 4 + 4 files changed, 182 insertions(+), 7 deletions(-) diff --git a/crates/core/shturman-power/src/main.rs b/crates/core/shturman-power/src/main.rs index 20d2d54..43a0a20 100644 --- a/crates/core/shturman-power/src/main.rs +++ b/crates/core/shturman-power/src/main.rs @@ -10,13 +10,44 @@ async fn main() -> anyhow::Result<()> { init_tracing("shturman-power"); let conn = connect().await?; let svc = PowerService::new(); + let fsm = svc.fsm_handle(); + let thermal_state = svc.thermal_state_handle(); + + // источники: dev = mock (управляется dev-D-Bus), prod = реальные (sysfs/UART) #[cfg(feature = "dev-mocks")] - let mock = svc.mock(); + let temp = std::sync::Arc::new(shturman_power::thermal::MockTempSource::new(20)); + #[cfg(not(feature = "dev-mocks"))] + let temp = std::sync::Arc::new(shturman_power::thermal::SysfsTempSource::new()); + let throttler = std::sync::Arc::new(shturman_power::thermal::NoopThrottler::default()); + #[cfg(feature = "dev-mocks")] + let copro = std::sync::Arc::new(shturman_power::coprocessor::MockCoprocessor::new()); + #[cfg(not(feature = "dev-mocks"))] + let copro = std::sync::Arc::new(shturman_power::coprocessor::SerialCoprocessor::new()); + + #[cfg(feature = "dev-mocks")] + let mock = svc.mock(temp.clone(), copro.clone()); + conn.object_server().at(names::power::PATH, svc).await?; #[cfg(feature = "dev-mocks")] conn.object_server().at(names::power::PATH, mock).await?; conn.request_name(names::power::NAME).await?; - tracing::info!("ru.shturman.Power1 на шине"); + + // контекст сигналов для фоновых циклов (после регистрации интерфейса) + let iface = conn + .object_server() + .interface::<_, PowerService>(names::power::PATH) + .await?; + let ctx = iface.signal_context().to_owned(); + shturman_power::service::spawn_loops( + fsm, + thermal_state, + temp as std::sync::Arc, + throttler as std::sync::Arc, + copro as std::sync::Arc, + ctx, + ); + + tracing::info!("ru.shturman.Power1 на шине (FSM + thermal + coprocessor)"); std::future::pending::<()>().await; Ok(()) } diff --git a/crates/core/shturman-power/src/service.rs b/crates/core/shturman-power/src/service.rs index d43754b..aad6eca 100644 --- a/crates/core/shturman-power/src/service.rs +++ b/crates/core/shturman-power/src/service.rs @@ -1,9 +1,12 @@ //! Server `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC). //! v0.3: оборачивает чистый `PowerFsm` (спека §5–§7). dev-mock кормит входы FSM (не флипает состояние). -use crate::fsm::{Action, Event, PowerFsm}; +use crate::coprocessor::{Coprocessor, CoprocessorClient, BROWNOUT_MV}; +use crate::fsm::{Action, Event, Phase, PowerFsm, State}; +use crate::protocol::McuToSoc; +use crate::thermal::{TempSource, ThermalLevel, ThermalMonitor, Throttler}; use shturman_common::monotonic_secs; -use shturman_ipc::types::{IgnitionState, PowerSource, PowerState}; +use shturman_ipc::types::{IgnitionState, PowerSource, PowerState, ShutdownReason}; use std::sync::{Arc, Mutex}; use std::time::Duration; use zbus::interface; @@ -14,12 +17,14 @@ const GRACE_SECS: u32 = 2; pub struct PowerService { fsm: Arc>, + thermal_state: Arc>, } impl Default for PowerService { fn default() -> Self { Self { fsm: Arc::new(Mutex::new(PowerFsm::new())), + thermal_state: Arc::new(Mutex::new(ThermalLevel::Normal)), } } } @@ -37,11 +42,23 @@ impl PowerService { pub fn source(&self) -> PowerSource { self.fsm.lock().unwrap().source() } + pub fn fsm_handle(&self) -> Arc> { + Arc::clone(&self.fsm) + } + pub fn thermal_state_handle(&self) -> Arc> { + Arc::clone(&self.thermal_state) + } #[cfg(feature = "dev-mocks")] - pub fn mock(&self) -> PowerMock { + pub fn mock( + &self, + temp: Arc, + copro: Arc, + ) -> PowerMock { PowerMock { fsm: Arc::clone(&self.fsm), + temp, + copro, } } } @@ -89,6 +106,99 @@ async fn apply_event( Ok(()) } +/// Фоновые циклы v0.4 — thermal-монитор + coprocessor (heartbeat/wait/safe-to-cut/B09). Монотоника. +#[allow(clippy::too_many_arguments)] +pub fn spawn_loops( + fsm: Arc>, + thermal_state: Arc>, + temp: Arc, + throttler: Arc, + copro: Arc, + ctx: SignalContext<'static>, +) { + // thermal-цикл + { + let (fsm, ctx) = (Arc::clone(&fsm), ctx.clone()); + tokio::spawn(async move { + let mut mon = ThermalMonitor::new(); + loop { + tokio::time::sleep(Duration::from_secs(crate::thermal::POLL_SECS)).await; + let t = temp.read_celsius(); + let obs = mon.observe(t); + if !obs.changed { + continue; + } + throttler.apply(obs.level); + *thermal_state.lock().unwrap() = obs.level; + let _ = PowerService::thermal_changed(&ctx, obs.level.as_str(), t).await; + if obs.entered_critical { + let _ = apply_event(&fsm, Event::ThermalTrip, &ctx).await; + } + if obs.left_critical { + let in_thermal_abortable = matches!( + fsm.lock().unwrap().state(), + State::ShuttingDown { + phase: Phase::Abortable, + reason: ShutdownReason::Thermal + } + ); + if in_thermal_abortable { + let _ = apply_event(&fsm, Event::ThermalCleared, &ctx).await; + } + } + } + }); + } + // coprocessor-цикл (heartbeat / wait-for-completion / safe-to-cut / B09 failsafe) + { + tokio::spawn(async move { + let mut client = CoprocessorClient::new(Arc::clone(&copro)); + let mut last_committed = false; + let mut last_shutting = false; + loop { + tokio::time::sleep(Duration::from_secs(crate::coprocessor::HEARTBEAT_SECS)).await; + copro.set_now(monotonic_secs()); + // входящие MCU→SoC → FSM + for msg in client.poll() { + match msg { + McuToSoc::Acc { on } => { + let ev = if on { Event::AccOn } else { Event::AccOff }; + let _ = apply_event(&fsm, ev, &ctx).await; + } + McuToSoc::Voltage { mv } if mv < BROWNOUT_MV => { + let _ = apply_event(&fsm, Event::UnderVoltage, &ctx).await; + } + _ => {} + } + } + // рёбра состояния FSM → протокол + let st = fsm.lock().unwrap().state(); + let shutting = matches!(st, State::ShuttingDown { .. }); + let committed = matches!( + st, + State::ShuttingDown { + phase: Phase::Committed, + .. + } + ); + if shutting && !last_shutting { + client.shutdown_imminent(crate::coprocessor::HOLDUP_BUDGET_SECS as u8); + } else if committed && !last_committed { + client.safe_to_cut(); // PONR → MCU режет немедленно + } else if !shutting { + client.heartbeat(); // running/accessory — keepalive + } + last_shutting = shutting; + last_committed = committed; + // B09: независимый fail-safe-таймер (зависший SoC / истёк бюджет) + if copro.failsafe_due() { + let _ = apply_event(&fsm, Event::FailsafeCut, &ctx).await; + } + } + }); + } +} + #[interface(name = "ru.shturman.Power1")] impl PowerService { async fn get_power_state(&self) -> String { @@ -110,10 +220,20 @@ impl PowerService { async fn power_source(&self) -> String { self.source().as_str().to_string() } + #[zbus(property)] + async fn thermal_state(&self) -> String { + self.thermal_state.lock().unwrap().as_str().to_string() + } #[zbus(signal)] async fn acc_changed(ctx: &SignalContext<'_>, on: bool) -> zbus::Result<()>; #[zbus(signal)] + async fn thermal_changed( + ctx: &SignalContext<'_>, + state: &str, + celsius: i32, + ) -> zbus::Result<()>; + #[zbus(signal)] async fn shutdown_imminent( ctx: &SignalContext<'_>, seconds: u32, @@ -131,6 +251,8 @@ impl PowerService { #[cfg(feature = "dev-mocks")] pub struct PowerMock { fsm: Arc>, + temp: Arc, + copro: Arc, } #[cfg(feature = "dev-mocks")] @@ -168,6 +290,21 @@ impl PowerMock { async fn abort_shutdown(&self, #[zbus(signal_context)] ctx: SignalContext<'_>) { let _ = apply_event(&self.fsm, Event::AccOn, &ctx).await; } + + /// Задать температуру (°C) → thermal-монитор подхватит на следующем poll. + async fn set_temp(&self, celsius: i32) { + self.temp.set(celsius); + } + + /// «Завис SoC»: heartbeat перестаёт освежать B09-таймер → MCU срежет питание. + async fn hang_soc(&self) { + self.copro.hang(); + } + + /// Тишина линка: SoC-сторона деградирует (лог, не self-cut — red-line). MCU-политика cut-vs-hold — B §12/HW. + async fn mcu_link_loss(&self) { + tracing::warn!("coprocessor: MCU link loss — SoC деградирует (cut-vs-hold политика — HW/§12)"); + } } #[cfg(test)] diff --git a/crates/core/shturman-power/tests/integration.rs b/crates/core/shturman-power/tests/integration.rs index e38afea..d0c806e 100644 --- a/crates/core/shturman-power/tests/integration.rs +++ b/crates/core/shturman-power/tests/integration.rs @@ -3,14 +3,17 @@ use futures_util::StreamExt; use shturman_ipc::{names, types::PowerState}; +use shturman_power::coprocessor::MockCoprocessor; +use shturman_power::thermal::MockTempSource; use shturman_power::PowerService; use shturman_sdk::PowerClient; +use std::sync::Arc; #[tokio::test] #[ignore = "нужна session-шина: just test-integration"] async fn power_state_and_fake_acc() { let svc = PowerService::new(); - let mock = svc.mock(); + let mock = svc.mock(Arc::new(MockTempSource::new(20)), Arc::new(MockCoprocessor::new())); // сервер: Power1 + dev.PowerMock1 на одном пути (владеет ru.shturman.Power) let server = zbus::Connection::session().await.unwrap(); @@ -53,7 +56,7 @@ async fn power_state_and_fake_acc() { #[ignore = "нужна session-шина: just test-integration"] async fn shutdown_imminent_then_abort() { let svc = PowerService::new(); - let mock = svc.mock(); + let mock = svc.mock(Arc::new(MockTempSource::new(20)), Arc::new(MockCoprocessor::new())); let server = zbus::Connection::session().await.unwrap(); server .object_server() diff --git a/crates/shturman-ipc/src/proxy.rs b/crates/shturman-ipc/src/proxy.rs index b80ef09..4f59d30 100644 --- a/crates/shturman-ipc/src/proxy.rs +++ b/crates/shturman-ipc/src/proxy.rs @@ -22,10 +22,14 @@ pub trait Power1 { fn uptime(&self) -> zbus::Result; #[zbus(property)] fn power_source(&self) -> zbus::Result; + #[zbus(property)] + fn thermal_state(&self) -> zbus::Result; #[zbus(signal)] fn acc_changed(&self, on: bool) -> zbus::Result<()>; #[zbus(signal)] + fn thermal_changed(&self, state: String, celsius: i32) -> zbus::Result<()>; + #[zbus(signal)] fn shutdown_imminent(&self, seconds: u32, reason: String) -> zbus::Result<()>; #[zbus(signal)] fn shutdown_aborted(&self) -> zbus::Result<()>;