feat(v0.3): Power-сервис на FSM — dev-mock кормит события, grace+durable-barrier

P7.2: service.rs оборачивает PowerFsm — D-Bus state/signals из FSM; apply_event
исполняет действия (эмит сигналов, фоновый grace-таймер, durable-barrier sync).
dev-mock SetAcc/SetIgnition/TriggerShutdown/AbortShutdown кормят входы FSM.
FSM: AccOff → AccChanged(false)+ShutdownImminent (сохранён walking-skeleton-регресс).
Integration: ShutdownImminent + abort. zbus → tokio-executor (default-features=false,
features=["tokio"]) — иначе tokio::spawn в хендлере паникует (async-io). test-integration
--test-threads=1 (тесты владеют одним именем на шине).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Alexander <akotenev2003@gmail.com>
This commit is contained in:
2026-06-24 23:17:13 +03:00
parent d8465c91e4
commit aaae0508b9
6 changed files with 151 additions and 81 deletions
Generated
+14 -17
View File
@@ -165,17 +165,6 @@ dependencies = [
"slab",
]
[[package]]
name = "async-fs"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
dependencies = [
"async-lock",
"blocking",
"futures-lite",
]
[[package]]
name = "async-io"
version = "2.6.0"
@@ -3885,6 +3874,16 @@ dependencies = [
"serde_core",
]
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
@@ -4209,11 +4208,14 @@ version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -5441,15 +5443,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
dependencies = [
"async-broadcast",
"async-executor",
"async-fs",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
@@ -5463,6 +5459,7 @@ dependencies = [
"serde_repr",
"sha1",
"static_assertions",
"tokio",
"tracing",
"uds_windows",
"windows-sys 0.52.0",
+2 -1
View File
@@ -22,7 +22,8 @@ rust-version = "1.96"
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] }
zbus = "4"
# tokio-executor у zbus (а не async-io) — сервисы на #[tokio::main]; нужно для tokio::spawn в хендлерах (grace-таймер).
zbus = { version = "4", default-features = false, features = ["tokio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
+15 -2
View File
@@ -102,7 +102,16 @@ impl PowerFsm {
self.state = Accessory;
vec![]
}
(Accessory | Running, E::AccOff) => self.begin_shutdown(ShutdownReason::AccOff),
// ACC-off: линия ACC сменилась (AccChanged) + старт shutdown.
(Accessory | Running, E::AccOff) => {
self.state = ShuttingDown { phase: Abortable, reason: ShutdownReason::AccOff };
vec![
Action::AccChanged(false),
Action::ShutdownImminent(ShutdownReason::AccOff),
Action::StartGrace,
]
}
// under-voltage/thermal: ACC не менялся → без AccChanged.
(Accessory | Running, E::UnderVoltage) => self.begin_shutdown(ShutdownReason::UnderVoltage),
(Accessory | Running, E::ThermalTrip) => self.begin_shutdown(ShutdownReason::Thermal),
(ShuttingDown { phase: Abortable, .. }, E::AccOn) => {
@@ -149,7 +158,11 @@ mod tests {
let mut f = PowerFsm::new(); // Running
assert_eq!(
f.step(Event::AccOff),
vec![Action::ShutdownImminent(ShutdownReason::AccOff), Action::StartGrace]
vec![
Action::AccChanged(false),
Action::ShutdownImminent(ShutdownReason::AccOff),
Action::StartGrace
]
);
assert_eq!(f.power_state(), PowerState::ShuttingDown);
assert_eq!(f.source(), PowerSource::HoldupCap);
+72 -59
View File
@@ -1,39 +1,24 @@
//! Server-стаб `ru.shturman.Power1` + (feature `dev-mocks`) `ru.shturman.dev.PowerMock1` (fake-ACC).
//! zbus 4: несколько интерфейсов на одном объекте — это РАЗНЫЕ типы на одном пути, разделяющие
//! состояние через `Arc<Mutex<State>>` (а не два `#[interface]` на одном типе).
//! 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 shturman_common::monotonic_secs;
use shturman_ipc::types::{IgnitionState, PowerSource, PowerState};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zbus::interface;
use zbus::object_server::SignalContext;
struct State {
power: PowerState,
ignition: IgnitionState,
source: PowerSource,
}
/// Grace-окно (сек): и поле сигнала `ShutdownImminent`, и длительность таймера. v0.3 — фикс. (конфиг — позже).
const GRACE_SECS: u32 = 2;
impl Default for State {
fn default() -> Self {
Self {
power: PowerState::Running,
ignition: IgnitionState::Running,
source: PowerSource::Vehicle12v,
}
}
}
/// Стаб питания (`Power1`). В v0 стартует в `running`; запись/actuator отсутствуют (#2).
pub struct PowerService {
state: Arc<Mutex<State>>,
fsm: Arc<Mutex<PowerFsm>>,
}
impl Default for PowerService {
fn default() -> Self {
Self {
state: Arc::new(Mutex::new(State::default())),
}
Self { fsm: Arc::new(Mutex::new(PowerFsm::new())) }
}
}
@@ -41,25 +26,60 @@ impl PowerService {
pub fn new() -> Self {
Self::default()
}
// Inherent-аксессоры (тесты + источник для interface-методов).
pub fn power_state(&self) -> PowerState {
self.state.lock().unwrap().power
self.fsm.lock().unwrap().power_state()
}
pub fn ignition(&self) -> IgnitionState {
self.state.lock().unwrap().ignition
self.fsm.lock().unwrap().ignition()
}
pub fn source(&self) -> PowerSource {
self.state.lock().unwrap().source
self.fsm.lock().unwrap().source()
}
/// dev-mock «fake-ACC», разделяющий состояние (только в dev-сборке).
#[cfg(feature = "dev-mocks")]
pub fn mock(&self) -> PowerMock {
PowerMock {
state: Arc::clone(&self.state),
PowerMock { fsm: Arc::clone(&self.fsm) }
}
}
/// Durable-write barrier (#5): сбросить грязные страницы `/data` ДО PONR (Settings уже синхронен).
fn durable_barrier() {
let _ = std::process::Command::new("sync").status();
tracing::info!(
"power: commit (PONR) — durable barrier sync; load-shed: amp/backlight/modem (нет реальных нагрузок в v0)"
);
}
/// Шагнуть FSM и исполнить действия (эмит сигналов, grace-таймер, durable-barrier).
async fn apply_event(
fsm: &Arc<Mutex<PowerFsm>>,
ev: Event,
ctx: &SignalContext<'_>,
) -> zbus::Result<()> {
let actions = fsm.lock().unwrap().step(ev);
for a in actions {
match a {
Action::ShutdownImminent(r) => {
PowerService::shutdown_imminent(ctx, GRACE_SECS, r.as_str()).await?
}
Action::ShutdownAborted => PowerService::shutdown_aborted(ctx).await?,
Action::AccChanged(on) => PowerService::acc_changed(ctx, on).await?,
Action::StartGrace => {
// Фоновый grace-таймер (монотоника tokio). По истечении — GraceExpired:
// commit (durable-barrier), если FSM ещё в abortable; если был re-power (abort) — no-op.
let fsm = Arc::clone(fsm);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(GRACE_SECS as u64)).await;
let acts = fsm.lock().unwrap().step(Event::GraceExpired);
if acts.contains(&Action::Commit) {
durable_barrier();
}
});
}
Action::Commit => durable_barrier(),
}
}
Ok(())
}
#[interface(name = "ru.shturman.Power1")]
@@ -68,19 +88,17 @@ impl PowerService {
self.power_state().as_str().to_string()
}
/// Внутренний; в v0-стабе — no-op (полная sleep/wake — v1/v2, B §7).
/// Внутренний; sleep/wake — v1/v2 (B §7). В v0.3 — no-op.
async fn request_sleep(&self) {}
#[zbus(property)]
async fn ignition_state(&self) -> String {
self.ignition().as_str().to_string()
}
#[zbus(property)]
async fn uptime(&self) -> u64 {
monotonic_secs()
}
#[zbus(property)]
async fn power_source(&self) -> String {
self.source().as_str().to_string()
@@ -102,51 +120,46 @@ impl PowerService {
async fn wake(ctx: &SignalContext<'_>) -> zbus::Result<()>;
}
/// dev-mock «fake-ACC» — отдельный тип на том же пути. Прод (`--no-default-features`) его НЕ регистрирует.
/// Методы возвращают `()` (ошибку эмита сигнала игнорируем — мок не отвечает D-Bus-ошибкой).
/// dev-mock «fake-ACC/voltage/thermal» — кормит входы FSM. Прод (`--no-default-features`) не регистрирует.
#[cfg(feature = "dev-mocks")]
pub struct PowerMock {
state: Arc<Mutex<State>>,
fsm: Arc<Mutex<PowerFsm>>,
}
#[cfg(feature = "dev-mocks")]
#[interface(name = "ru.shturman.dev.PowerMock1")]
impl PowerMock {
async fn set_acc(&self, on: bool, #[zbus(signal_context)] ctx: SignalContext<'_>) {
{
let mut st = self.state.lock().unwrap();
st.ignition = if on {
IgnitionState::Running
} else {
IgnitionState::Off
};
st.power = if on {
PowerState::Running
} else {
PowerState::Off
};
}
// Эмитим Power1-сигнал (тот же путь; имя интерфейса добавляет сама acc_changed).
let _ = PowerService::acc_changed(&ctx, on).await;
let ev = if on { Event::AccOn } else { Event::AccOff };
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn set_ignition(&self, state: String) {
if let Ok(ig) = state.parse::<IgnitionState>() {
self.state.lock().unwrap().ignition = ig;
}
async fn set_ignition(&self, state: String, #[zbus(signal_context)] ctx: SignalContext<'_>) {
// accessory↔running — через EngineOn/Off; off — AccOff.
let ev = match state.as_str() {
"running" => Event::EngineOn,
"accessory" => Event::EngineOff,
_ => Event::AccOff,
};
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn trigger_shutdown(
&self,
seconds: u32,
_seconds: u32,
reason: String,
#[zbus(signal_context)] ctx: SignalContext<'_>,
) {
let _ = PowerService::shutdown_imminent(&ctx, seconds, &reason).await;
let ev = match reason.as_str() {
"thermal" => Event::ThermalTrip,
"under_voltage" => Event::UnderVoltage,
_ => Event::AccOff,
};
let _ = apply_event(&self.fsm, ev, &ctx).await;
}
async fn abort_shutdown(&self, #[zbus(signal_context)] ctx: SignalContext<'_>) {
let _ = PowerService::shutdown_aborted(&ctx).await;
let _ = apply_event(&self.fsm, Event::AccOn, &ctx).await;
}
}
@@ -48,3 +48,48 @@ async fn power_state_and_fake_acc() {
let sig = acc.next().await.unwrap();
assert!(!sig.args().unwrap().on());
}
#[tokio::test]
#[ignore = "нужна session-шина: just test-integration"]
async fn shutdown_imminent_then_abort() {
let svc = PowerService::new();
let mock = svc.mock();
let server = zbus::Connection::session().await.unwrap();
server.object_server().at(names::power::PATH, svc).await.unwrap();
server.object_server().at(names::power::PATH, mock).await.unwrap();
server.request_name(names::power::NAME).await.unwrap();
let client = zbus::Connection::session().await.unwrap();
let power = PowerClient::new(&client).await.unwrap();
let mut imminent = power.proxy().receive_shutdown_imminent().await.unwrap();
let mut aborted = power.proxy().receive_shutdown_aborted().await.unwrap();
// ACC-off → ShutdownImminent(acc_off), состояние shutting_down
client
.call_method(
Some(names::power::NAME),
names::power::PATH,
Some(names::power::MOCK_IFACE),
"SetAcc",
&(false,),
)
.await
.unwrap();
let sig = imminent.next().await.unwrap();
assert_eq!(sig.args().unwrap().reason(), "acc_off");
assert_eq!(power.power_state().await.unwrap(), PowerState::ShuttingDown);
// re-power до grace → ShutdownAborted + running
client
.call_method(
Some(names::power::NAME),
names::power::PATH,
Some(names::power::MOCK_IFACE),
"SetAcc",
&(true,),
)
.await
.unwrap();
aborted.next().await.unwrap();
assert_eq!(power.power_state().await.unwrap(), PowerState::Running);
}
+3 -2
View File
@@ -22,9 +22,10 @@ lint:
deny:
cargo deny check
# интеграционные тесты на session-шине (нужен dbus: brew install dbus / в Lima)
# интеграционные тесты на session-шине (нужен dbus: brew install dbus / в Lima).
# --test-threads=1: тесты владеют одними well-known именами на общей шине → серийно (иначе кросс-talk/вис).
test-integration:
dbus-run-session -- cargo test --workspace -- --ignored
dbus-run-session -- cargo test --workspace -- --ignored --test-threads=1
# полный локальный гейт
ci: lint test deny