From d12fbf291a9d32e5b204fc63478cab5f9b03ef8e Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:14:05 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.9.0=20=E2=80=94=20Power=20Management?= =?UTF-8?q?=20Rebuild=20(=C2=A7Phase=2014=20milestone=2053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces rcp/powerstate.hpp in full, per the Satellite Package Disposition table's entry for this file: the pre-replacement ad-hoc Active/Sleeping/BusOff model built on rcp.hpp's Zone/CommandType/ Controller is discarded, not adapted. Nothing else in the tree depended on that old API (only this file's own test did), so no legacy shim was needed, same as rcp/e2e.hpp's equivalent note at v2.6.0. - PowerMode gains the specification's actual four modes (Normal, StandBy, Sleep, Unpowered); start_kind_on_exit fixes StandBy as always a hot start and Sleep as always a cold start, exposed on PowerManager as pending_start_kind(). - PowerManager::enter_standby/enter_sleep apply the three documented entry-refusal conditions in a fixed order: an unacknowledged wake-up event (queried directly from rcp::wakeup::WakeupEndpoint::wakeup_message_pending()), a non-idle endpoint, and a non-empty response/ack queue — the latter two exposed as caller-supplied Hooks::endpoints_idle/ Hooks::response_ack_queues_empty predicates rather than a direct rcp/regmap.hpp dependency, mirroring rcp/lifecycle.hpp's PlausibilityCheck pattern. - resume_from_standby implements StandBy's hot-start rule directly (no handshake, straight back to Normal). - begin_wake_from_sleep/note_wakeup_attempt_sent/acknowledge_wakeup implement the hot-start-from-Sleep handshake by directly driving WakeupEndpoint::wakeup_message_pending()/acknowledge_wakeup() in a repeat-until-echoed-or-limit loop (Config::wakeup_repeat_limit, default 8, this implementation's own choice), with Hooks::reenable_network_interface/ Hooks::reenable_response_ack_queues covering the handshake's step 1 and step 3. - notify_power_removed/notify_power_restored model Unpowered as a hardware-driven, unconditional transition with no refusal path. New coverage lives in tests/test_powerstate.cpp (REQ-PWR-001..014, entirely rewritten), traced in .fusa-reqs.json. Bumps kVersion/project version to 2.9.0. Updates ROADMAP.md with a Done (v2.9.0) summary for milestone 53. Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 80 +++++-- CMakeLists.txt | 2 +- ROADMAP.md | 33 +++ include/rcp/powerstate.hpp | 432 ++++++++++++++++++++++++------------ include/rcp/version.hpp | 2 +- tests/test_powerstate.cpp | 436 +++++++++++++++++++++++-------------- 6 files changed, 661 insertions(+), 324 deletions(-) diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 4d4b767..a91ac87 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -1010,64 +1010,112 @@ }, { "id": "REQ-PWR-001", - "title": "sleep() sends CmdSleep", - "text": "powerstate::Manager::sleep() shall send CommandType::Sleep to the target zone.", + "title": "PowerManager starts in Normal with four defined modes", + "text": "rcp::powerstate::PowerMode shall define exactly four modes (Normal, StandBy, Sleep, Unpowered), and a freshly constructed PowerManager shall report Normal as its initial mode() value.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-PWR-002", - "title": "sleep() transitions Active\u2192Sleeping", - "text": "A successful sleep() call shall transition the zone from Active to Sleeping.", + "title": "enter_standby/enter_sleep are only defined from Normal", + "text": "PowerManager::enter_standby and PowerManager::enter_sleep shall succeed only when mode() is Normal, transitioning to the requested low-power mode, and shall return PowerErrc::invalid_transition without changing mode() when called from any other mode.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-PWR-003", - "title": "wake() sends CmdWake", - "text": "powerstate::Manager::wake() shall send CommandType::Wake to the target zone.", + "title": "Entry refusal: unacknowledged wake-up event", + "text": "PowerManager::enter_standby/enter_sleep shall return PowerErrc::unacknowledged_wakeup_event without changing mode() whenever the associated WakeupEndpoint::wakeup_message_pending() reports true.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-PWR-004", - "title": "wake() transitions Sleeping\u2192Active", - "text": "A successful wake() call shall transition the zone from Sleeping or BusOff to Active.", + "title": "Entry refusal: non-idle endpoint", + "text": "PowerManager::enter_standby/enter_sleep shall return PowerErrc::endpoint_not_idle without changing mode() whenever the configured Hooks::endpoints_idle predicate returns false.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-PWR-005", - "title": "Command failure yields BusOff", - "text": "A send error during sleep() or wake() shall transition the zone to BusOff.", + "title": "Entry refusal: non-empty response/ack queue", + "text": "PowerManager::enter_standby/enter_sleep shall return PowerErrc::response_ack_queue_not_empty without changing mode() whenever the configured Hooks::response_ack_queues_empty predicate returns false.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-PWR-006", - "title": "recover_loop retries BusOff zones", - "text": "The background recovery loop shall periodically attempt wake() for every zone in the BusOff state.", + "title": "StandBy always resumes as a hot start with no handshake", + "text": "PowerManager::resume_from_standby shall, when mode() is StandBy, transition directly to Normal without invoking any hot-start-from-Sleep handshake hook, and shall return PowerErrc::invalid_transition without changing mode() when called from any other mode.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-PWR-007", - "title": "state() is thread-safe", - "text": "powerstate::Manager::state(z) shall be safe to call concurrently with sleep(), wake(), or the recovery loop.", + "title": "begin_wake_from_sleep requires Sleep and re-enables the network interface", + "text": "PowerManager::begin_wake_from_sleep shall, when mode() is Sleep, invoke the configured Hooks::reenable_network_interface hook exactly once and set wake_stage() to HandshakeActive while leaving mode() at Sleep, and shall return PowerErrc::not_asleep without side effects when mode() is not Sleep.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-PWR-008", - "title": "Close stops recovery loop", - "text": "Manager::close() shall stop the background recovery thread without hanging.", + "title": "Handshake steps are refused outside of an active handshake", + "text": "PowerManager::note_wakeup_attempt_sent and PowerManager::acknowledge_wakeup shall both return PowerErrc::not_asleep without side effects whenever wake_stage() is not HandshakeActive.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWR-009", + "title": "note_wakeup_attempt_sent enforces the WakeUp repeat limit", + "text": "PowerManager::note_wakeup_attempt_sent shall return PowerErrc::handshake_repeat_limit_exceeded and set wake_stage() to Failed once it has been called more than Config::wakeup_repeat_limit times since begin_wake_from_sleep while the associated WakeupEndpoint::wakeup_message_pending() continues to report true, and mode() shall remain Sleep when this occurs.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWR-010", + "title": "acknowledge_wakeup completes the handshake and returns to Normal", + "text": "PowerManager::acknowledge_wakeup shall, while wake_stage() is HandshakeActive, call the associated WakeupEndpoint::acknowledge_wakeup, invoke the configured Hooks::reenable_response_ack_queues hook exactly once, set wake_stage() to Complete, and transition mode() to Normal.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWR-011", + "title": "notify_power_removed forces Unpowered unconditionally", + "text": "PowerManager::notify_power_removed shall set mode() to Unpowered regardless of the current mode() or any entry-refusal condition that would otherwise apply to enter_standby/enter_sleep.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWR-012", + "title": "notify_power_restored returns to Normal from Unpowered", + "text": "PowerManager::notify_power_restored shall set mode() to Normal and wake_stage() to Idle.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWR-013", + "title": "start_kind_on_exit fixes StandBy as hot and Sleep as cold", + "text": "rcp::powerstate::start_kind_on_exit shall return StartKind::Hot for PowerMode::StandBy and StartKind::Cold for PowerMode::Sleep, and PowerManager::pending_start_kind shall report start_kind_on_exit(mode()).", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" + }, + { + "id": "REQ-PWR-014", + "title": "PowerErrc is a distinct error category with a non-empty message", + "text": "rcp::powerstate::make_error_code(PowerErrc::invalid_transition) shall report rcp::powerstate::power_category() as its category and shall have a non-empty diagnostic message.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" diff --git a/CMakeLists.txt b/CMakeLists.txt index 716f2f7..2311de2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.7.1 + VERSION 2.9.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/ROADMAP.md b/ROADMAP.md index 332053d..2139f86 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -750,6 +750,39 @@ disposition table below for the full reasoning per package. - Depends on: Wakeup control endpoint (v2.7.0), RC Server lifecycle (v2.1.0) +**Done (v2.9.0):** `rcp/powerstate.hpp` is replaced in full, per the +Satellite Package Disposition table's entry for this file — the prior +ad-hoc `Active`/`Sleeping`/`BusOff` model built on `rcp.hpp`'s +`Zone`/`CommandType`/`Controller` is discarded, not adapted. `PowerMode` +gains the specification's actual four modes (`Normal`/`StandBy`/`Sleep`/ +`Unpowered`); `start_kind_on_exit` fixes `StandBy` as always `Hot` and +`Sleep` as always `Cold`, exposed on `PowerManager` as +`pending_start_kind()`. `PowerManager::enter_standby`/`enter_sleep` apply +the three entry-refusal conditions in a fixed order — an unacknowledged +wake-up event (queried directly from `rcp::wakeup::WakeupEndpoint:: +wakeup_message_pending()`), a non-idle endpoint, and a non-empty +response/ack queue — with the latter two exposed as caller-supplied +`Hooks::endpoints_idle`/`Hooks::response_ack_queues_empty` predicates +rather than a direct `rcp/regmap.hpp` dependency, mirroring +`rcp/lifecycle.hpp`'s `PlausibilityCheck` pattern. `resume_from_standby` +implements the hot-start rule directly (no handshake, straight back to +`Normal`); `begin_wake_from_sleep`/`note_wakeup_attempt_sent`/ +`acknowledge_wakeup` implement the hot-start-from-Sleep handshake by +directly driving `WakeupEndpoint::wakeup_message_pending()`/ +`acknowledge_wakeup()` in a repeat-until-echoed-or-limit loop +(`Config::wakeup_repeat_limit`, default 8, this implementation's own +choice), with `Hooks::reenable_network_interface`/ +`Hooks::reenable_response_ack_queues` covering the handshake's step 1 and +step 3. `notify_power_removed`/`notify_power_restored` model `Unpowered` +as a hardware-driven, unconditional transition with no refusal path. +Nothing else in the tree depended on the old `powerstate::Manager` API +(only this file's own test did), so no legacy shim was needed, same as +`rcp/e2e.hpp`'s equivalent note at v2.6.0. New coverage lives in +`tests/test_powerstate.cpp` (`REQ-PWR-001..014`, entirely rewritten), +traced in `.fusa-reqs.json`. Full bit-for-bit conformance against other +TC18 implementations is not claimed — see this file's own disclaimer +pattern in `rcp/lifecycle.hpp`/`rcp/regmap.hpp`/`rcp/wakeup.hpp`. + ### 54. Watchdog & Liveness Rebuild (v2.10.0) - Replace `watchdog.hpp`'s client-driven periodic `CommandType::Watchdog` diff --git a/include/rcp/powerstate.hpp b/include/rcp/powerstate.hpp index 46e18eb..498b38d 100644 --- a/include/rcp/powerstate.hpp +++ b/include/rcp/powerstate.hpp @@ -6,191 +6,347 @@ // fusa:req REQ-PWR-006 // fusa:req REQ-PWR-007 // fusa:req REQ-PWR-008 +// fusa:req REQ-PWR-009 +// fusa:req REQ-PWR-010 +// fusa:req REQ-PWR-011 +// fusa:req REQ-PWR-012 +// fusa:req REQ-PWR-013 +// fusa:req REQ-PWR-014 -// Zone controller power state manager (SG-003, ISO 26262 ASIL-B). +// Power management — the OPEN Alliance TC18 Remote Control Protocol +// Specification v0.5.1_RC's actual power-mode model (`Normal`/`StandBy`/ +// `Sleep`/`Unpowered`), the entry-refusal conditions guarding a transition +// into either low-power mode, and the hot-start-from-Sleep wake handshake +// (extraction §3.3, §3.4). // -// A Manager sends CommandType::Sleep / CommandType::Wake to zone controllers -// and tracks the resulting power state. When a command fails the zone -// transitions to BusOff and the Manager retries CommandType::Wake at the -// configured recovery interval. +// ROADMAP.md milestone 53, "Power Management Rebuild (v2.9.0)", opening +// Phase 14: this header REPLACES this file's pre-replacement content in +// full, per the Satellite Package Disposition table's entry for +// `powerstate.hpp` — the prior ad-hoc `Active`/`Sleeping`/`BusOff` model +// built on rcp.hpp's `Zone`/`CommandType`/`Controller` is discarded, not +// adapted. Nothing else in this tree depended on that old API (only this +// file's own test did), so no legacy shim is needed here, same as +// rcp/e2e.hpp's equivalent note at v2.6.0. +// +// This header rides directly on rcp/wakeup.hpp's `WakeupEndpoint` +// (v2.7.0) — the wake-source/handshake primitives it exposes +// (`wakeup_message_pending`/`acknowledge_wakeup`) are driven from here +// rather than reimplemented, same layering style rcp/e2e.hpp established +// for reusing rcp/request.hpp's `RequestLedger::cancel_all` — and on +// rcp/lifecycle.hpp's `ServerLifecycle` only informally, in that both +// headers model a forward-driven state machine gated by caller-supplied +// plausibility/readiness checks rather than any shared base type. This +// header does not itself reach into rcp/regmap.hpp's register-map types: +// the "non-idle endpoint" and "non-empty response/ack queue" entry-refusal +// checks are exposed as caller-supplied predicates (`Hooks::endpoints_idle`, +// `Hooks::response_ack_queues_empty` below) rather than this header walking +// a `RegisterMap` itself, mirroring rcp/lifecycle.hpp's own +// `PlausibilityCheck` pattern for the same reason: keeping this header +// usable without hard-wiring it to one concrete register-map shape. +// +// Field names and behavior below implement TC18's *behavior* as described +// in an internal structured extraction of the specification named above; +// no text from that document is reproduced here. The concrete class shapes, +// the wake-handshake repeat-limit default, and the exact split of +// responsibility across the methods below are this implementation's own +// encoding of that behavior — full bit-for-bit conformance against other +// TC18 implementations is not claimed, same as the equivalent disclaimers +// in rcp/lifecycle.hpp, rcp/regmap.hpp, and rcp/wakeup.hpp. This header +// provides primitives, not a running scheduler, timer, or transport of its +// own — deciding *when* to retransmit the WakeUp message or poll for its +// echo is left to the embedding application's driver layer, same +// disclaimer as every other endpoint/lifecycle header in this codebase. #pragma once -#include "rcp.hpp" +#include -#include -#include +#include #include -#include -#include -#include #include -#include -#include +#include namespace rcp { namespace powerstate { -// ── PowerState ──────────────────────────────────────────────────────────────── +// ── PowerMode ───────────────────────────────────────────────────────────────── +// The four power modes an RC Server can be in (extraction §3.3). Values are +// this implementation's own numbering — the specification's own bit-level +// encoding, if any, is not claimed here. -enum class PowerState : uint8_t { - Active = 0, - Sleeping = 1, - BusOff = 2, +enum class PowerMode : uint8_t { + Normal = 0, // fully operational + StandBy = 1, // low-power; always resumed via a hot start (see StartKind) + Sleep = 2, // low-power; always resumed via a cold start (see StartKind) + Unpowered = 3, // no power at all; entered/exited by hardware events, not requested }; -inline std::string to_string(PowerState p) { - switch (p) { - case PowerState::Active: return "active"; - case PowerState::Sleeping: return "sleeping"; - case PowerState::BusOff: return "bus-off"; +inline std::string to_string(PowerMode m) { + switch (m) { + case PowerMode::Normal: return "normal"; + case PowerMode::StandBy: return "standby"; + case PowerMode::Sleep: return "sleep"; + case PowerMode::Unpowered: return "unpowered"; default: return "unknown"; } } -struct PowerEvent { - Zone zone; - PowerState state; - std::error_code err; +// ── StartKind ───────────────────────────────────────────────────────────────── +// Whether resuming to Normal from a given low-power mode is a hot or cold +// start (extraction §3.4). This is a fixed property of *which* low-power +// mode was entered, not a runtime choice: StandBy is always a hot start +// (state is retained; PowerManager::resume_from_standby needs no handshake +// at all) and Sleep is always a cold start (state is not retained; the +// hot-start-from-Sleep *handshake* below is the wire-level wake procedure +// that precedes that cold start, not a contradiction of it — see +// PowerManager's class comment). + +enum class StartKind : uint8_t { + Hot = 0, + Cold = 1, +}; + +inline std::string to_string(StartKind k) { + return k == StartKind::Hot ? "hot" : "cold"; +} + +// start_kind_on_exit reports the fixed start kind for resuming from `from`. +// Only meaningful for StandBy/Sleep; Normal/Unpowered have no defined +// "resume" transition of their own (Unpowered's recovery is necessarily a +// cold start too, by construction — see notify_power_restored below — but +// that is a distinct, hardware-driven path, not one this function reports +// on). +constexpr StartKind start_kind_on_exit(PowerMode from) noexcept { + return from == PowerMode::StandBy ? StartKind::Hot : StartKind::Cold; +} + +// ── Errors ──────────────────────────────────────────────────────────────────── + +enum class PowerErrc : int { + invalid_transition = 1, // requested transition is not defined from the current mode + unacknowledged_wakeup_event = 2, // entry refusal: WakeupEndpoint still owes a WakeUp repetition + endpoint_not_idle = 3, // entry refusal: caller's endpoints_idle hook reported false + response_ack_queue_not_empty = 4, // entry refusal: caller's response_ack_queues_empty hook reported false + not_asleep = 5, // wake-from-sleep handshake step requested while mode() != Sleep + handshake_repeat_limit_exceeded = 6, // WakeUp message repeated cfg.wakeup_repeat_limit times with no echo +}; + +inline const std::error_category& power_category() noexcept { + struct Cat : std::error_category { + const char* name() const noexcept override { return "rcp.powerstate"; } + std::string message(int ev) const override { + switch (static_cast(ev)) { + case PowerErrc::invalid_transition: + return "rcp/powerstate: requested transition is not defined from the current mode"; + case PowerErrc::unacknowledged_wakeup_event: + return "rcp/powerstate: entry refused — an unacknowledged wake-up event is pending"; + case PowerErrc::endpoint_not_idle: + return "rcp/powerstate: entry refused — at least one endpoint is not idle"; + case PowerErrc::response_ack_queue_not_empty: + return "rcp/powerstate: entry refused — a response/ack queue is not empty"; + case PowerErrc::not_asleep: + return "rcp/powerstate: wake-from-sleep handshake requested while not in Sleep"; + case PowerErrc::handshake_repeat_limit_exceeded: + return "rcp/powerstate: WakeUp message repeat limit exceeded without an echo"; + default: + return "rcp/powerstate: unknown error"; + } + } + }; + static Cat instance; + return instance; +} + +inline std::error_code make_error_code(PowerErrc e) noexcept { + return {static_cast(e), power_category()}; +} + +// ── WakeStage ───────────────────────────────────────────────────────────────── +// Progress of the hot-start-from-Sleep handshake (extraction §3.3). Idle +// outside of an active wake-from-sleep sequence. + +enum class WakeStage : uint8_t { + Idle = 0, // no wake-from-sleep sequence in progress + HandshakeActive = 1, // network interface re-enabled; repeating WakeUp until echoed or limit hit + Complete = 2, // echoed, response/ack queues re-enabled, mode() == Normal + Failed = 3, // repeat limit exceeded without an echo; mode() is still Sleep }; // ── Config ──────────────────────────────────────────────────────────────────── struct Config { - std::chrono::milliseconds recovery_interval{100}; - std::chrono::milliseconds recovery_timeout {50}; + // The specification leaves the concrete WakeUp-message repeat limit to + // the implementation (extraction §3.3); this default is this + // implementation's own choice. + uint32_t wakeup_repeat_limit = 8; }; inline Config default_config() { return Config{}; } -// ── Manager ─────────────────────────────────────────────────────────────────── - -class Manager { +// ── PowerManager ────────────────────────────────────────────────────────────── +// PowerManager owns the current PowerMode and drives every transition +// described above. It has no clock, thread, or transport of its own — see +// this file's header comment. +// +// A note on naming: extraction §3.3 names the wire-level wake-up procedure +// used when leaving Sleep the "hot start" handshake — network-interface +// re-enablement followed by a repeating WakeUp message — even though +// leaving Sleep is *itself* always a cold start of the server's retained +// state (extraction §3.4; see StartKind above). Those are two different +// axes: StartKind describes whether application-level state survived the +// low-power mode; the handshake below describes the wire-level procedure +// used to bring the network interface back up before that cold start can +// proceed. This header follows the extraction's own terminology for the +// handshake's name (begin_wake_from_sleep, not begin_cold_start) rather +// than reconciling the two into a single term of this implementation's own +// invention. +class PowerManager { public: - using PowerCallback = std::function; + // true return means "every managed endpoint is idle" / "every + // response/ack queue is empty" — see this file's header comment for why + // these are caller-supplied hooks rather than a direct rcp/regmap.hpp + // dependency. + using EndpointIdleCheck = std::function; + using QueueEmptyCheck = std::function; + using NetworkReenable = std::function; + using QueueReenable = std::function; - explicit Manager(Config cfg, std::vector> ctrls) - : cfg_(cfg) { - for (auto& c : ctrls) { - ctrls_[c->zone()] = c; - states_[c->zone()] = PowerState::Active; - } - recover_thread_ = std::thread([this]{ recover_loop(); }); - } + struct Hooks { + EndpointIdleCheck endpoints_idle; // entry-refusal condition (b) + QueueEmptyCheck response_ack_queues_empty; // entry-refusal condition (c) + NetworkReenable reenable_network_interface; // hot-start-from-Sleep step 1 + QueueReenable reenable_response_ack_queues; // hot-start-from-Sleep step 3 + }; - ~Manager() { - closed_.store(true, std::memory_order_release); - if (recover_thread_.joinable()) recover_thread_.join(); - } + explicit PowerManager(wakeup::WakeupEndpoint& wakeup_ep, Hooks hooks = {}, + Config cfg = default_config()) noexcept + : wakeup_ep_(wakeup_ep), hooks_(std::move(hooks)), cfg_(cfg) {} - PowerState state(Zone z) const { - std::lock_guard lk(mu_); - auto it = states_.find(z); - return it != states_.end() ? it->second : PowerState::BusOff; - } + PowerMode mode() const noexcept { return mode_; } + WakeStage wake_stage() const noexcept { return wake_stage_; } + uint32_t wake_attempts() const noexcept { return wake_attempts_; } - std::error_code sleep(const Context& ctx, Zone z) { - auto ctrl = get_ctrl(z); - if (!ctrl) return ErrNotFound; - { - std::lock_guard lk(mu_); - if (states_[z] != PowerState::Active) - return make_error_code(Errc::busy); - } - Command cmd; - cmd.zone = z; - cmd.type = CommandType::Sleep; - cmd.priority = Priority::High; - Response resp; - auto ec = ctrl->send(ctx, cmd, resp); - transition(z, ec ? PowerState::BusOff : PowerState::Sleeping, ec); - return ec; - } + // pending_start_kind reports the fixed start kind that resuming from the + // current mode will be (only meaningful while mode() is StandBy or + // Sleep — see start_kind_on_exit). + StartKind pending_start_kind() const noexcept { return start_kind_on_exit(mode_); } - std::error_code wake(const Context& ctx, Zone z) { - auto ctrl = get_ctrl(z); - if (!ctrl) return ErrNotFound; - { - std::lock_guard lk(mu_); - if (states_[z] == PowerState::Active) - return make_error_code(Errc::busy); - } - Command cmd; - cmd.zone = z; - cmd.type = CommandType::Wake; - cmd.priority = Priority::High; - Response resp; - auto ec = ctrl->send(ctx, cmd, resp); - transition(z, ec ? PowerState::BusOff : PowerState::Active, ec); - return ec; + // enter_standby / enter_sleep request a transition from Normal into the + // named low-power mode. Both apply the same three entry-refusal checks, + // in the fixed order below, since extraction §3.3/§3.4 define them + // identically regardless of which low-power mode is being entered: + // 1. an unacknowledged wake-up event (WakeupEndpoint still owes a + // WakeUp repetition from a prior cycle) + // 2. a non-idle endpoint (Hooks::endpoints_idle) + // 3. a non-empty response/ack queue (Hooks::response_ack_queues_empty) + // A hook left unset is treated as "check passes" (nothing to refuse + // on), same convention as rcp/lifecycle.hpp's PlausibilityCheck. + std::error_code enter_standby() noexcept { return enter_low_power(PowerMode::StandBy); } + std::error_code enter_sleep() noexcept { return enter_low_power(PowerMode::Sleep); } + + // resume_from_standby implements StandBy's fixed hot-start rule + // directly: no network re-enablement, no WakeUp handshake — state was + // retained, so the manager returns straight to Normal. + std::error_code resume_from_standby() noexcept { + if (mode_ != PowerMode::StandBy) + return make_error_code(PowerErrc::invalid_transition); + mode_ = PowerMode::Normal; + wake_stage_ = WakeStage::Idle; + return {}; } - void subscribe(PowerCallback cb) { - std::lock_guard lk(mu_); - callbacks_.push_back(std::move(cb)); + // begin_wake_from_sleep starts the hot-start-from-Sleep handshake + // (extraction §3.3): step 1, network-interface re-enablement, runs + // synchronously here via Hooks::reenable_network_interface. Requires + // mode() == Sleep. + std::error_code begin_wake_from_sleep() noexcept { + if (mode_ != PowerMode::Sleep) + return make_error_code(PowerErrc::not_asleep); + if (hooks_.reenable_network_interface) hooks_.reenable_network_interface(); + wake_attempts_ = 0; + wake_stage_ = WakeStage::HandshakeActive; + return {}; } - void close() { - closed_.store(true, std::memory_order_release); + // note_wakeup_attempt_sent records that the caller's transport layer + // just (re)transmitted the WakeUp message once — step 2's repetition + // (extraction §3.3). Must be called only while wake_stage() == + // HandshakeActive. Once wakeup_ep_'s handshake is no longer pending + // (already echoed, e.g. acknowledge_wakeup() was called concurrently by + // the driver layer) this is a harmless no-op. Exceeding + // cfg.wakeup_repeat_limit moves wake_stage() to Failed and leaves + // mode() at Sleep — the caller decides whether/when to retry. + std::error_code note_wakeup_attempt_sent() noexcept { + if (wake_stage_ != WakeStage::HandshakeActive) + return make_error_code(PowerErrc::not_asleep); + if (!wakeup_ep_.wakeup_message_pending()) return {}; + if (++wake_attempts_ > cfg_.wakeup_repeat_limit) { + wake_stage_ = WakeStage::Failed; + return make_error_code(PowerErrc::handshake_repeat_limit_exceeded); + } + return {}; } -private: - Config cfg_; - mutable std::mutex mu_; - std::map> ctrls_; - std::map states_; - std::vector callbacks_; - std::atomic closed_{false}; - std::thread recover_thread_; - - std::shared_ptr get_ctrl(Zone z) const { - std::lock_guard lk(mu_); - auto it = ctrls_.find(z); - return it != ctrls_.end() ? it->second : nullptr; + // acknowledge_wakeup completes step 2 once the far end has echoed the + // WakeUp message, then carries out step 3 (Hooks::reenable_response_ack_queues) + // and returns the manager to Normal. Must be called only while + // wake_stage() == HandshakeActive. + std::error_code acknowledge_wakeup() noexcept { + if (wake_stage_ != WakeStage::HandshakeActive) + return make_error_code(PowerErrc::not_asleep); + wakeup_ep_.acknowledge_wakeup(); + if (hooks_.reenable_response_ack_queues) hooks_.reenable_response_ack_queues(); + mode_ = PowerMode::Normal; + wake_stage_ = WakeStage::Complete; + return {}; } - void transition(Zone z, PowerState next, std::error_code ec) { - std::lock_guard lk(mu_); - if (states_[z] == next) return; - states_[z] = next; - PowerEvent ev{z, next, ec}; - for (auto& cb : callbacks_) cb(ev); + // notify_power_removed models an unconditional hardware power-loss + // event. Unlike enter_standby/enter_sleep, there is no refusal path + // here: power removal is reported, not requested. + void notify_power_removed() noexcept { + mode_ = PowerMode::Unpowered; + wake_stage_ = WakeStage::Idle; } - void recover_loop() { - while (!closed_.load(std::memory_order_acquire)) { - attempt_recovery(); - std::this_thread::sleep_for(cfg_.recovery_interval); - } + // notify_power_restored models power returning. Recovering from + // Unpowered necessarily loses all volatile state, so — like waking from + // Sleep — it is a cold start; this header does not model the full + // re-initialization sequence beyond returning mode() to Normal, since + // register-map/lifecycle reconfiguration after a cold start is + // rcp/lifecycle.hpp's concern, not this one's. + void notify_power_restored() noexcept { + mode_ = PowerMode::Normal; + wake_stage_ = WakeStage::Idle; } - void attempt_recovery() { - std::vector bus_off; - { - std::lock_guard lk(mu_); - for (auto& kv : states_) { - if (kv.second == PowerState::BusOff) bus_off.push_back(kv.first); - } - } - for (Zone z : bus_off) { - auto ctrl = get_ctrl(z); - if (!ctrl) continue; - Command cmd; - cmd.zone = z; - cmd.type = CommandType::Wake; - cmd.priority = Priority::High; - auto ctx = Context::with_timeout(cfg_.recovery_timeout); - Response resp; - auto ec = ctrl->send(ctx, cmd, resp); - if (!ec) transition(z, PowerState::Active, {}); - } +private: + wakeup::WakeupEndpoint& wakeup_ep_; + Hooks hooks_; + Config cfg_; + PowerMode mode_ = PowerMode::Normal; + WakeStage wake_stage_ = WakeStage::Idle; + uint32_t wake_attempts_ = 0; + + std::error_code enter_low_power(PowerMode target) noexcept { + if (mode_ != PowerMode::Normal) + return make_error_code(PowerErrc::invalid_transition); + if (wakeup_ep_.wakeup_message_pending()) + return make_error_code(PowerErrc::unacknowledged_wakeup_event); + if (hooks_.endpoints_idle && !hooks_.endpoints_idle()) + return make_error_code(PowerErrc::endpoint_not_idle); + if (hooks_.response_ack_queues_empty && !hooks_.response_ack_queues_empty()) + return make_error_code(PowerErrc::response_ack_queue_not_empty); + mode_ = target; + wake_stage_ = WakeStage::Idle; + return {}; } }; -inline std::unique_ptr new_manager( - Config cfg, - std::vector> ctrls) { - return std::make_unique(cfg, std::move(ctrls)); -} - } // namespace powerstate } // namespace rcp + +// Enable std::error_code construction from rcp::powerstate::PowerErrc. +namespace std { +template <> +struct is_error_code_enum : true_type {}; +} // namespace std diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index d9dff9c..018986c 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.7.1"; +constexpr std::string_view kVersion = "2.9.0"; } // namespace rcp diff --git a/tests/test_powerstate.cpp b/tests/test_powerstate.cpp index a40b9f5..23a0f2f 100644 --- a/tests/test_powerstate.cpp +++ b/tests/test_powerstate.cpp @@ -6,177 +6,277 @@ // fusa:test REQ-PWR-006 // fusa:test REQ-PWR-007 // fusa:test REQ-PWR-008 +// fusa:test REQ-PWR-009 +// fusa:test REQ-PWR-010 +// fusa:test REQ-PWR-011 +// fusa:test REQ-PWR-012 +// fusa:test REQ-PWR-013 +// fusa:test REQ-PWR-014 + +// Tests for rcp/powerstate.hpp — the TC18 power-mode model, entry-refusal +// conditions, and hot-start-from-Sleep handshake (ROADMAP.md milestone 53, +// "Power Management Rebuild", v2.9.0, Phase 14). -// Power-state Manager tests (SG-003, ISO 26262 ASIL-B). -// -// Verifies Sleep/Wake command emission and the Active/Sleeping/BusOff state -// machine, including automatic recovery of BusOff zones by the background loop. #include -#include "rcp/mock.hpp" #include "rcp/powerstate.hpp" +#include "rcp/wakeup.hpp" + +using namespace rcp::powerstate; + +// ── PowerMode / initial state ──────────────────────────────────────────────── + +TEST_CASE("A freshly constructed PowerManager starts in Normal", "[powerstate][REQ-PWR-001]") { + rcp::wakeup::WakeupEndpoint wep; + PowerManager mgr(wep); + REQUIRE(mgr.mode() == PowerMode::Normal); + REQUIRE(to_string(PowerMode::Normal) == "normal"); + REQUIRE(to_string(PowerMode::StandBy) == "standby"); + REQUIRE(to_string(PowerMode::Sleep) == "sleep"); + REQUIRE(to_string(PowerMode::Unpowered) == "unpowered"); +} + +// ── enter_standby / enter_sleep: only defined from Normal ─────────────────── + +TEST_CASE("enter_standby and enter_sleep succeed from Normal", "[powerstate][REQ-PWR-002]") { + rcp::wakeup::WakeupEndpoint wep; + PowerManager mgr(wep); + REQUIRE_FALSE(mgr.enter_standby()); + REQUIRE(mgr.mode() == PowerMode::StandBy); + + rcp::wakeup::WakeupEndpoint wep2; + PowerManager mgr2(wep2); + REQUIRE_FALSE(mgr2.enter_sleep()); + REQUIRE(mgr2.mode() == PowerMode::Sleep); +} + +TEST_CASE("enter_standby/enter_sleep are refused outside of Normal", "[powerstate][REQ-PWR-002]") { + rcp::wakeup::WakeupEndpoint wep; + PowerManager mgr(wep); + REQUIRE_FALSE(mgr.enter_standby()); + REQUIRE(mgr.mode() == PowerMode::StandBy); + + auto ec = mgr.enter_sleep(); + REQUIRE(ec == make_error_code(PowerErrc::invalid_transition)); + REQUIRE(mgr.mode() == PowerMode::StandBy); // unchanged + + ec = mgr.enter_standby(); + REQUIRE(ec == make_error_code(PowerErrc::invalid_transition)); +} + +// ── Entry refusal: unacknowledged wake-up event ────────────────────────────── + +TEST_CASE("Entry into StandBy/Sleep is refused while a wake-up event is unacknowledged", + "[powerstate][REQ-PWR-003]") { + rcp::wakeup::WakeupEndpoint wep; + wep.record_wake_source_event(2); // arms wakeup_message_pending() + REQUIRE(wep.wakeup_message_pending()); + + PowerManager mgr(wep); + auto ec = mgr.enter_sleep(); + REQUIRE(ec == make_error_code(PowerErrc::unacknowledged_wakeup_event)); + REQUIRE(mgr.mode() == PowerMode::Normal); + + // Once acknowledged, entry succeeds. + wep.acknowledge_wakeup(); + REQUIRE_FALSE(mgr.enter_sleep()); + REQUIRE(mgr.mode() == PowerMode::Sleep); +} + +// ── Entry refusal: non-idle endpoint ───────────────────────────────────────── + +TEST_CASE("Entry into StandBy/Sleep is refused while the endpoints_idle hook reports false", + "[powerstate][REQ-PWR-004]") { + rcp::wakeup::WakeupEndpoint wep; + bool idle = false; + PowerManager::Hooks hooks; + hooks.endpoints_idle = [&] { return idle; }; + PowerManager mgr(wep, hooks); + + auto ec = mgr.enter_standby(); + REQUIRE(ec == make_error_code(PowerErrc::endpoint_not_idle)); + REQUIRE(mgr.mode() == PowerMode::Normal); + + idle = true; + REQUIRE_FALSE(mgr.enter_standby()); + REQUIRE(mgr.mode() == PowerMode::StandBy); +} + +// ── Entry refusal: non-empty response/ack queue ────────────────────────────── + +TEST_CASE("Entry into StandBy/Sleep is refused while the response_ack_queues_empty hook reports false", + "[powerstate][REQ-PWR-005]") { + rcp::wakeup::WakeupEndpoint wep; + bool empty = false; + PowerManager::Hooks hooks; + hooks.response_ack_queues_empty = [&] { return empty; }; + PowerManager mgr(wep, hooks); + + auto ec = mgr.enter_sleep(); + REQUIRE(ec == make_error_code(PowerErrc::response_ack_queue_not_empty)); + REQUIRE(mgr.mode() == PowerMode::Normal); + + empty = true; + REQUIRE_FALSE(mgr.enter_sleep()); + REQUIRE(mgr.mode() == PowerMode::Sleep); +} + +// ── StandBy: always a hot start, no handshake ──────────────────────────────── + +TEST_CASE("resume_from_standby returns directly to Normal with no handshake", + "[powerstate][REQ-PWR-006]") { + rcp::wakeup::WakeupEndpoint wep; + bool network_reenabled = false; + PowerManager::Hooks hooks; + hooks.reenable_network_interface = [&] { network_reenabled = true; }; + PowerManager mgr(wep, hooks); + + REQUIRE_FALSE(mgr.enter_standby()); + REQUIRE(mgr.pending_start_kind() == StartKind::Hot); + + REQUIRE_FALSE(mgr.resume_from_standby()); + REQUIRE(mgr.mode() == PowerMode::Normal); + REQUIRE(mgr.wake_stage() == WakeStage::Idle); + REQUIRE_FALSE(network_reenabled); // hot start from StandBy never touches the handshake hooks +} + +TEST_CASE("resume_from_standby is refused outside of StandBy", "[powerstate][REQ-PWR-006]") { + rcp::wakeup::WakeupEndpoint wep; + PowerManager mgr(wep); + auto ec = mgr.resume_from_standby(); + REQUIRE(ec == make_error_code(PowerErrc::invalid_transition)); +} + +// ── Sleep: hot-start-from-Sleep handshake (cold start overall) ────────────── + +TEST_CASE("begin_wake_from_sleep requires Sleep and runs network re-enablement", + "[powerstate][REQ-PWR-007]") { + rcp::wakeup::WakeupEndpoint wep; + int reenable_calls = 0; + PowerManager::Hooks hooks; + hooks.reenable_network_interface = [&] { ++reenable_calls; }; + PowerManager mgr(wep, hooks); + + REQUIRE_FALSE(mgr.enter_sleep()); + REQUIRE(mgr.pending_start_kind() == StartKind::Cold); + + REQUIRE_FALSE(mgr.begin_wake_from_sleep()); + REQUIRE(reenable_calls == 1); + REQUIRE(mgr.wake_stage() == WakeStage::HandshakeActive); + REQUIRE(mgr.mode() == PowerMode::Sleep); // still Sleep until acknowledged +} + +TEST_CASE("Handshake steps are refused when not applicable", "[powerstate][REQ-PWR-008]") { + rcp::wakeup::WakeupEndpoint wep; + PowerManager mgr(wep); + + // Not asleep at all. + REQUIRE(mgr.begin_wake_from_sleep() == make_error_code(PowerErrc::not_asleep)); + REQUIRE(mgr.note_wakeup_attempt_sent() == make_error_code(PowerErrc::not_asleep)); + REQUIRE(mgr.acknowledge_wakeup() == make_error_code(PowerErrc::not_asleep)); + + // Asleep, but handshake not yet begun. + REQUIRE_FALSE(mgr.enter_sleep()); + REQUIRE(mgr.note_wakeup_attempt_sent() == make_error_code(PowerErrc::not_asleep)); + REQUIRE(mgr.acknowledge_wakeup() == make_error_code(PowerErrc::not_asleep)); +} + +TEST_CASE("note_wakeup_attempt_sent enforces the repeat limit", "[powerstate][REQ-PWR-009]") { + rcp::wakeup::WakeupEndpoint wep; + + Config cfg; + cfg.wakeup_repeat_limit = 3; + PowerManager mgr(wep, {}, cfg); + + REQUIRE_FALSE(mgr.enter_sleep()); + wep.record_wake_source_event(0); // a wake-source pin fires while asleep, arming the handshake + REQUIRE_FALSE(mgr.begin_wake_from_sleep()); + + // Never acknowledged: wep keeps reporting wakeup_message_pending(), so + // every attempt counts against the limit. + REQUIRE_FALSE(mgr.note_wakeup_attempt_sent()); // 1 + REQUIRE_FALSE(mgr.note_wakeup_attempt_sent()); // 2 + REQUIRE_FALSE(mgr.note_wakeup_attempt_sent()); // 3 + auto ec = mgr.note_wakeup_attempt_sent(); // 4 -> exceeds limit + REQUIRE(ec == make_error_code(PowerErrc::handshake_repeat_limit_exceeded)); + REQUIRE(mgr.wake_stage() == WakeStage::Failed); + REQUIRE(mgr.mode() == PowerMode::Sleep); // still Sleep — no silent recovery +} + +TEST_CASE("note_wakeup_attempt_sent is a no-op once the handshake is already echoed", + "[powerstate][REQ-PWR-009]") { + rcp::wakeup::WakeupEndpoint wep; + PowerManager mgr(wep); + REQUIRE_FALSE(mgr.enter_sleep()); + wep.record_wake_source_event(0); + REQUIRE_FALSE(mgr.begin_wake_from_sleep()); + + wep.acknowledge_wakeup(); // echoed out-of-band (e.g. by a concurrent driver call) + REQUIRE_FALSE(mgr.note_wakeup_attempt_sent()); + REQUIRE(mgr.wake_stage() == WakeStage::HandshakeActive); // acknowledge_wakeup() itself completes the stage +} + +TEST_CASE("acknowledge_wakeup completes the handshake, re-enables queues, and returns to Normal", + "[powerstate][REQ-PWR-010]") { + rcp::wakeup::WakeupEndpoint wep; + + int reenable_network = 0; + int reenable_queues = 0; + PowerManager::Hooks hooks; + hooks.reenable_network_interface = [&] { ++reenable_network; }; + hooks.reenable_response_ack_queues = [&] { ++reenable_queues; }; + PowerManager mgr(wep, hooks); + + REQUIRE_FALSE(mgr.enter_sleep()); + wep.record_wake_source_event(0); + REQUIRE_FALSE(mgr.begin_wake_from_sleep()); + REQUIRE(reenable_network == 1); + REQUIRE(reenable_queues == 0); // not yet — only after acknowledge + + REQUIRE_FALSE(mgr.note_wakeup_attempt_sent()); + REQUIRE_FALSE(mgr.acknowledge_wakeup()); + + REQUIRE(reenable_queues == 1); + REQUIRE_FALSE(wep.wakeup_message_pending()); + REQUIRE(mgr.wake_stage() == WakeStage::Complete); + REQUIRE(mgr.mode() == PowerMode::Normal); +} + +// ── Unpowered ───────────────────────────────────────────────────────────────── + +TEST_CASE("notify_power_removed forces Unpowered unconditionally", "[powerstate][REQ-PWR-011]") { + rcp::wakeup::WakeupEndpoint wep; + wep.record_wake_source_event(0); // would normally block entry into a low-power mode + + PowerManager mgr(wep); + mgr.notify_power_removed(); + REQUIRE(mgr.mode() == PowerMode::Unpowered); // no refusal path for power loss +} + +TEST_CASE("notify_power_restored returns to Normal from Unpowered", "[powerstate][REQ-PWR-012]") { + rcp::wakeup::WakeupEndpoint wep; + PowerManager mgr(wep); + mgr.notify_power_removed(); + REQUIRE(mgr.mode() == PowerMode::Unpowered); + + mgr.notify_power_restored(); + REQUIRE(mgr.mode() == PowerMode::Normal); + REQUIRE(mgr.wake_stage() == WakeStage::Idle); +} + +// ── StartKind ───────────────────────────────────────────────────────────────── + +TEST_CASE("start_kind_on_exit reports Hot for StandBy and Cold for Sleep", "[powerstate][REQ-PWR-013]") { + REQUIRE(start_kind_on_exit(PowerMode::StandBy) == StartKind::Hot); + REQUIRE(start_kind_on_exit(PowerMode::Sleep) == StartKind::Cold); + REQUIRE(to_string(StartKind::Hot) == "hot"); + REQUIRE(to_string(StartKind::Cold) == "cold"); +} + +// ── PowerErrc category sanity ──────────────────────────────────────────────── -#include -#include -#include -#include -#include - -using namespace rcp; -using namespace std::chrono_literals; - -namespace { - -// AlwaysFail fails every send with a fixed error and counts attempts. -class AlwaysFail final : public rcp::Controller { -public: - AlwaysFail(Zone z, std::error_code ec) : zone_(z), ec_(ec) {} - Zone zone() const noexcept override { return zone_; } - std::error_code send(const Context&, const Command&, Response&) override { - sends.fetch_add(1, std::memory_order_relaxed); - return ec_; - } - std::error_code subscribe(const Context&, std::shared_ptr&) override { - return ec_; - } - std::error_code close() override { return {}; } - std::atomic sends{0}; -private: - Zone zone_; - std::error_code ec_; -}; - -// FailThenOk fails the first `fail_count` sends, then succeeds. -class FailThenOk final : public rcp::Controller { -public: - FailThenOk(Zone z, int fail_count) : zone_(z), remaining_(fail_count) {} - Zone zone() const noexcept override { return zone_; } - std::error_code send(const Context&, const Command&, Response& out) override { - if (remaining_.fetch_sub(1, std::memory_order_acq_rel) > 0) - return ErrTimeout; - out = Response{0, zone_, ResponseStatus::OK, {}}; - return {}; - } - std::error_code subscribe(const Context&, std::shared_ptr&) override { - return {}; - } - std::error_code close() override { return {}; } -private: - Zone zone_; - std::atomic remaining_; -}; - -template -bool wait_until(Pred p, std::chrono::milliseconds deadline = 5s) { - auto end = std::chrono::steady_clock::now() + deadline; - while (std::chrono::steady_clock::now() < end) { - if (p()) return true; - std::this_thread::sleep_for(2ms); - } - return p(); -} - -} // namespace - -TEST_CASE("powerstate: sleep sends CmdSleep and transitions Active->Sleeping", - "[powerstate][REQ-PWR-001][REQ-PWR-002]") { - std::atomic last_type{-1}; - auto ctrl = std::make_shared( - Zone::FrontLeft, [&](const Command& c) { - last_type.store(static_cast(c.type)); - return Response{c.id, Zone::FrontLeft, ResponseStatus::OK, {}}; - }); - auto mgr = powerstate::new_manager(powerstate::default_config(), {ctrl}); - - REQUIRE_FALSE(mgr->sleep(Context{}, Zone::FrontLeft)); - REQUIRE(last_type.load() == static_cast(CommandType::Sleep)); // REQ-PWR-001 - REQUIRE(mgr->state(Zone::FrontLeft) == powerstate::PowerState::Sleeping); // REQ-PWR-002 -} - -TEST_CASE("powerstate: wake sends CmdWake and transitions Sleeping->Active", - "[powerstate][REQ-PWR-003][REQ-PWR-004]") { - std::atomic last_type{-1}; - auto ctrl = std::make_shared( - Zone::FrontLeft, [&](const Command& c) { - last_type.store(static_cast(c.type)); - return Response{c.id, Zone::FrontLeft, ResponseStatus::OK, {}}; - }); - auto mgr = powerstate::new_manager(powerstate::default_config(), {ctrl}); - - REQUIRE_FALSE(mgr->sleep(Context{}, Zone::FrontLeft)); - REQUIRE(mgr->state(Zone::FrontLeft) == powerstate::PowerState::Sleeping); - - REQUIRE_FALSE(mgr->wake(Context{}, Zone::FrontLeft)); - REQUIRE(last_type.load() == static_cast(CommandType::Wake)); // REQ-PWR-003 - REQUIRE(mgr->state(Zone::FrontLeft) == powerstate::PowerState::Active); // REQ-PWR-004 -} - -TEST_CASE("powerstate: command failure yields BusOff", "[powerstate][REQ-PWR-005]") { - auto ctrl = std::make_shared(Zone::Central, ErrTimeout); - powerstate::Config cfg; - cfg.recovery_interval = 1h; // effectively disable recovery for this test - auto mgr = powerstate::new_manager(cfg, {ctrl}); - - auto ec = mgr->sleep(Context{}, Zone::Central); - REQUIRE(ec); // the underlying send failed - REQUIRE(mgr->state(Zone::Central) == powerstate::PowerState::BusOff); -} - -TEST_CASE("powerstate: recover_loop retries BusOff zones", "[powerstate][REQ-PWR-006]") { - // First send (the sleep) fails -> BusOff; the recovery loop's Wake succeeds. - auto ctrl = std::make_shared(Zone::RearLeft, /*fail_count=*/1); - powerstate::Config cfg; - cfg.recovery_interval = 10ms; - cfg.recovery_timeout = 10ms; - auto mgr = powerstate::new_manager(cfg, {ctrl}); - - REQUIRE(mgr->sleep(Context{}, Zone::RearLeft)); // fails -> BusOff - REQUIRE(mgr->state(Zone::RearLeft) == powerstate::PowerState::BusOff); - - // Background recovery should bring the zone back to Active. - REQUIRE(wait_until([&] { - return mgr->state(Zone::RearLeft) == powerstate::PowerState::Active; - })); -} - -TEST_CASE("powerstate: state() is thread-safe", "[powerstate][REQ-PWR-007]") { - auto ctrl = std::make_shared(Zone::FrontRight); - auto mgr = powerstate::new_manager(powerstate::default_config(), {ctrl}); - - std::atomic stop{false}; - std::vector readers; - for (int i = 0; i < 4; ++i) { - readers.emplace_back([&] { - while (!stop.load()) { - auto s = mgr->state(Zone::FrontRight); - REQUIRE((s == powerstate::PowerState::Active || - s == powerstate::PowerState::Sleeping || - s == powerstate::PowerState::BusOff)); - } - }); - } - for (int i = 0; i < 50; ++i) { - auto e1 = mgr->sleep(Context{}, Zone::FrontRight); (void)e1; - auto e2 = mgr->wake(Context{}, Zone::FrontRight); (void)e2; - } - stop.store(true); - for (auto& t : readers) t.join(); -} - -TEST_CASE("powerstate: close stops the recovery loop", "[powerstate][REQ-PWR-008]") { - auto ctrl = std::make_shared(Zone::RearRight, ErrTimeout); - powerstate::Config cfg; - cfg.recovery_interval = 10ms; - cfg.recovery_timeout = 10ms; - auto mgr = powerstate::new_manager(cfg, {ctrl}); - - REQUIRE(mgr->sleep(Context{}, Zone::RearRight)); // -> BusOff, recovery starts retrying - REQUIRE(wait_until([&] { return ctrl->sends.load() >= 3; })); // loop is active - - mgr->close(); - std::this_thread::sleep_for(50ms); // let any in-flight attempt settle - int after_close = ctrl->sends.load(); - std::this_thread::sleep_for(100ms); // several recovery_intervals - // No further send attempts once the loop has stopped (allow one in-flight). - REQUIRE(ctrl->sends.load() <= after_close + 1); +TEST_CASE("PowerErrc reports a non-empty message in its own category", "[powerstate][REQ-PWR-014]") { + auto ec = make_error_code(PowerErrc::invalid_transition); + REQUIRE(ec.category() == power_category()); + REQUIRE_FALSE(ec.message().empty()); }