From 836cd34b1f018751a4647f41c47b8fda347fcce7 Mon Sep 17 00:00:00 2001 From: Steven Ohmert Date: Tue, 19 Aug 2025 11:02:48 -0700 Subject: [PATCH 1/6] WIP starts --- .../src/how/ec/integration/integration.md | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/guide_book/src/how/ec/integration/integration.md b/guide_book/src/how/ec/integration/integration.md index a480f17..d57595a 100644 --- a/guide_book/src/how/ec/integration/integration.md +++ b/guide_book/src/how/ec/integration/integration.md @@ -1,2 +1,95 @@ # Integration -_TODO: Integration of battery, charger, and thermal services into the embedded controller._ \ No newline at end of file + +Before we turn our attention to making an embedded build to a hardware target, we want to make sure that we have a working integration of the +components in a virtual environment. This will allow us to test the interactions between the components and ensure that they work together as expected before we move on to the embedded build. + +In this section, we will cover the integration of all of our example components working together. +This integration will be similar to the previous examples, but with some additional complexity due to the interaction between the components. We will also explore how to test the integration of these components and ensure that they work together as expected. + +## A simulation +We will build this integration as both an integration test and as an executable app that runs the simulation of the components in action. This simulator will allows us to increase/decrease the load, mimicking the behavior of a real system, and we can then observe how the components interact with each other to keep the battery charged and the system cool over differing operating conditions. + +## Starting with comms + +We will start by building the communication layer that will allow the components to interact with each other. This will involve setting up the message passing system that will allow the components to send and receive messages, as well as setting up the service registry that will allow the components to discover each other. + +We've done much of this before. If you will recall the Battery and Charger integration tests, we extended the `EspiService` structure from supporting a single BatteryChannel to supporting a ChargerChannel as well. Now we will extend it further to support a ThermalChannel. + +### Setting up the integration project +We will create a new project space for this integration, rather than trying to shoehorn it into the existing battery or charger projects. This will allow us to keep the integration code separate from the component code, making it easier to manage and test. + +Create a new project directory in the `ec_examples` directory named `integration_project`. Give it a `Cargo.toml` file with the following content: + +```toml +# Battery-Charger Subsystem +[package] +name = "integration_project" +version = "0.1.0" +edition = "2024" +resolver = "2" +description = "System-level integration sim wiring Battery, Charger, and Thermal" + + +# We'll declare both a lib (for tests) and a bin (for the simulator) +[lib] +name = "integration_project" +path = "src/lib.rs" + +[[bin]] +name = "integration_sim" +path = "src/main.rs" + + +[dependencies] +embedded-batteries-async = { workspace = true } +embassy-executor = { workspace = true } +embassy-time = { workspace = true } +embassy-sync = { workspace = true } +embassy-futures = { workspace = true } +embassy-time-driver = { workspace = true } +embassy-time-queue-utils = { workspace = true } + +embedded-services = { workspace = true } +battery-service = { workspace = true } + +ec_common = { path = "../ec_common"} +mock_battery = { path = "../battery_project/mock_battery", default-features = false} +mock_charger = { path = "../charger_project/mock_charger", default-features = false} +mock_thermal = { path = "../thermal_project/mock_thermal", default-features = false} + +# Logging for the simulator +log = { version = "0.4", optional = true } +env_logger = { version = "0.11", optional = true } + +static_cell = "2.1" +futures = "0.3" +heapless = "0.8" + +[features] +default = ["std", "thread-mode"] +std = [] +thread-mode = [ + "mock_battery/thread-mode", + "mock_charger/thread-mode", + "mock_thermal/thread-mode" +] +noop-mode = [ + "mock_battery/noop-mode", + "mock_charger/noop-mode", + "mock_thermal/noop-mode" +] +``` + +Next, edit the `ec_examples/Cargo.toml` at the top level to add `integration_project` as a workspace member: + +```toml + members = [ + "battery_project/mock_battery", + "charger_project/mock_charger", + "thermal_project/mock_thermal", + "battery_charger_subsystem", + "integration_project", + "ec_common" +] +``` + From 16c216ffb1fe82cb14b1e6d7785baad50519645c Mon Sep 17 00:00:00 2001 From: Steven Ohmert Date: Sat, 20 Sep 2025 15:03:22 -0700 Subject: [PATCH 2/6] Completion of Integration section --- guide_book/src/SUMMARY.md | 24 +- .../src/how/ec/integration/1-integration.md | 201 ++++++ .../how/ec/integration/10-controller_core.md | 413 +++++++++++ .../src/how/ec/integration/11-first_tests.md | 195 +++++ .../ec/integration/12-tasks_and_listeners.md | 261 +++++++ .../ec/integration/13-integration_logic.md | 74 ++ .../ec/integration/14-display_rendering.md | 498 +++++++++++++ .../src/how/ec/integration/15-interaction.md | 139 ++++ .../how/ec/integration/16-in_place_render.md | 288 ++++++++ .../how/ec/integration/17-integration_test.md | 181 +++++ .../18-integration_test_structure.md | 549 ++++++++++++++ .../how/ec/integration/19-meaningful_tests.md | 253 +++++++ .../src/how/ec/integration/2-move_events.md | 675 ++++++++++++++++++ .../ec/integration/20-charger_attachment.md | 67 ++ .../how/ec/integration/21-affecting_change.md | 252 +++++++ .../how/ec/integration/22-summary_thoughts.md | 31 + .../src/how/ec/integration/3-better_alloc.md | 27 + .../how/ec/integration/4-update_controller.md | 450 ++++++++++++ .../how/ec/integration/5-structural_steps.md | 249 +++++++ .../how/ec/integration/6-scaffold_start.md | 71 ++ .../src/how/ec/integration/7-setup_and_tap.md | 205 ++++++ .../how/ec/integration/8-battery_adapter.md | 306 ++++++++ .../how/ec/integration/9-system_observer.md | 308 ++++++++ .../src/how/ec/integration/integration.md | 95 --- .../how/ec/integration/integration_tests.md | 3 - .../ec/integration/media/integration-sim.png | Bin 0 -> 26264 bytes 26 files changed, 5715 insertions(+), 100 deletions(-) create mode 100644 guide_book/src/how/ec/integration/1-integration.md create mode 100644 guide_book/src/how/ec/integration/10-controller_core.md create mode 100644 guide_book/src/how/ec/integration/11-first_tests.md create mode 100644 guide_book/src/how/ec/integration/12-tasks_and_listeners.md create mode 100644 guide_book/src/how/ec/integration/13-integration_logic.md create mode 100644 guide_book/src/how/ec/integration/14-display_rendering.md create mode 100644 guide_book/src/how/ec/integration/15-interaction.md create mode 100644 guide_book/src/how/ec/integration/16-in_place_render.md create mode 100644 guide_book/src/how/ec/integration/17-integration_test.md create mode 100644 guide_book/src/how/ec/integration/18-integration_test_structure.md create mode 100644 guide_book/src/how/ec/integration/19-meaningful_tests.md create mode 100644 guide_book/src/how/ec/integration/2-move_events.md create mode 100644 guide_book/src/how/ec/integration/20-charger_attachment.md create mode 100644 guide_book/src/how/ec/integration/21-affecting_change.md create mode 100644 guide_book/src/how/ec/integration/22-summary_thoughts.md create mode 100644 guide_book/src/how/ec/integration/3-better_alloc.md create mode 100644 guide_book/src/how/ec/integration/4-update_controller.md create mode 100644 guide_book/src/how/ec/integration/5-structural_steps.md create mode 100644 guide_book/src/how/ec/integration/6-scaffold_start.md create mode 100644 guide_book/src/how/ec/integration/7-setup_and_tap.md create mode 100644 guide_book/src/how/ec/integration/8-battery_adapter.md create mode 100644 guide_book/src/how/ec/integration/9-system_observer.md delete mode 100644 guide_book/src/how/ec/integration/integration.md delete mode 100644 guide_book/src/how/ec/integration/integration_tests.md create mode 100644 guide_book/src/how/ec/integration/media/integration-sim.png diff --git a/guide_book/src/SUMMARY.md b/guide_book/src/SUMMARY.md index e3eb689..3d42542 100644 --- a/guide_book/src/SUMMARY.md +++ b/guide_book/src/SUMMARY.md @@ -96,8 +96,28 @@ - [Service Prep](./how/ec/thermal/9-service_prep.md) - [Thermal Service](./how/ec/thermal/10-service_registry.md) - [Tests](./how/ec/thermal/11-tests.md) - - [Integration](./how/ec/integration/integration.md) - -[Integration Tests](./how/ec/integration/integration_tests.md) + - [Integration](./how/ec/integration/1-integration.md) + - [Move Events](./how/ec/integration/2-move_events.md) + - [Better Alloc](./how/ec/integration/3-better_alloc.md) + - [Update Controller](./how/ec/integration/4-update_controller.md) + - [Structure Steps](./how/ec/integration/5-structural_steps.md) + - [Scaffold Start](./how/ec/integration/6-scaffold_start.md) + - [Setup and Tap](./how/ec/integration/7-setup_and_tap.md) + - [Battery Adapter](./how/ec/integration/8-battery_adapter.md) + - [System Observer](./how/ec/integration/9-system_observer.md) + - [Controller Core](./how/ec/integration/10-controller_core.md) + - [First Tests](./how/ec/integration/11-first_tests.md) + - [Tasks and Listeners](./how/ec/integration/12-tasks_and_listeners.md) + - [Integration Logic](./how/ec/integration/13-integration_logic.md) + - [Display Rendering](./how/ec/integration/14-display_rendering.md) + - [Interaction](./how/ec/integration/15-interaction.md) + - [In Place Rendering](./how/ec/integration/16-in_place_render.md) + - [Integration Test](./how/ec/integration/17-integration_test.md) + - [Test Structure](./how/ec/integration/18-integration_test_structure.md) + - [Meaningful Tests](./how/ec/integration/19-meaningful_tests.md) + - [Charger Attach](./how/ec/integration/20-charger_attachment.md) + - [Affecting Change](./how/ec/integration/21-affecting_change.md) + - [Summary Thoughts](./how/ec/integration/22-summary_thoughts.md) - [Embedded Targeting](./how/ec/embedded_target/embedded_targeting.md) - [Project Board](./how/ec/embedded_target/project_board.md) - [Dependencies](./how/ec/embedded_target/embedded_dependencies.md) diff --git a/guide_book/src/how/ec/integration/1-integration.md b/guide_book/src/how/ec/integration/1-integration.md new file mode 100644 index 0000000..7521d9b --- /dev/null +++ b/guide_book/src/how/ec/integration/1-integration.md @@ -0,0 +1,201 @@ +# Integration + +Before we turn our attention to making an embedded build to a hardware target, we want to make sure that we have a working integration of the +components in a virtual environment. This will allow us to test the interactions between the components and ensure that they work together as expected ahead of moving onto the embedded build. + +In this section, we will cover the integration of all of our example components working together. +This integration will be similar to the previous examples, but with some additional complexity due to the interaction between the components. We will also explore how to test the integration of these components and ensure that they work together as expected. In the process, we will also make a more engaging, interactive application for evaluating our combined creation locally. + +## About the Battery-Charger Integration +In our previous integration exercise, we realized we needed to restructure much of our project structure to allow proper code accessibility to build the integration. +Refactoring is a normal part of a development process as complexity grows and patterns of component interoperability begin to emerge. +We did restructure the code in that effort. However, for the most part, we simply moved ahead with the same service integration and message handling established with the very first component creation. This included introducing ownership-rule defying patterns such as the `duplicate_static_mut!` copy macro that allowed us to get around Rust rules for double-borrow. We could assert that this was safe because we could audit all the uses ourselves and verify that no harm would come, even though Rust's static analysis, unable to share that birdseye view of things, would not agree. But these forms of assertions all too easily become overconfident declarations of hubris and just because we _say_ something is safe, doesn't mean it is, especially when components begin getting plugged together in various new ways, and especially in an environment that strives for seamless interchangeability of component models. + +_After all, what is the point of the type-safe advantages in Rust when you choose to treat it like C?_ + +In this integration -- where we bring together all of the components we have created, we want to make sure we have a strong and defensible integration model design before +we move on to embedded targeting where flaws in our design will be less tolerated. + +Several parts of our previous integrations, on review, are flawed: +- The already mentioned use of `unsafe` code workarounds and inconsistent ownership patterns. +- Unnecessary use of Generics when constructing components. Generics come with additional overhead and are more complicated to write for, so use of them superficially should be discouraged. +- Failure to use the `battery-service` event processing - even though we created and registered our BatteryDevice, we didn't start the service that uses it. + +## A more unified structure +A problem we have seen that quickly becomes even more complicated as we bring this integration together is the issue of a single, unified ownership scope. We've already noted how having separate component instances that we try to pass around to various worker tasks runs quickly into the multiple borrow violations problem. + +To combat this more structurally, we'll define a single structure, `ControllerCore`, that will own all of the components directly, and access to this at a task level will be managed by a mutex to ensure we don't run into any race condition behavior. These patterns are enforceable by Rust's static analysis, so if it complains, we know we've crossed a line and shouldn't resort to cheating with `unsafe` casts or else we will face consequences. + +>## New approach benefits +> - single owner `ControllerCore` +> - consolidated BusEvent channel for messages +> - OnceLock + Mutex pattern +> - removal of gratuitous generics +> ---- + +### Breaking some eggs +Addressing these changes will require some minor revisions in our previous definitions for `MockBatteryController` and `MockChargerController`. Although the changes are minor, they will have significant impact upon the previous projects and they will no longer build. As they say, making omelets requires breaking some eggs. These past projects could be resurrected by adopting some of the new constructor patterns we will introduce here, but that will be left as an exercise for the reader. + +## A simulation +We will build this integration as both an integration test and as an executable app that runs the simulation of the components in action. This simulator will allow us to increase/decrease the load, mimicking the behavior of a real system, and we can then observe how the components interact with each other to keep the battery charged and the system cool over differing operating conditions. + +### Setting up the integration project +We will set up a new project space for this integration, rather than trying to shoehorn it into the existing battery or charger projects. This will allow us to keep the integration code separate from the component code, making it easier to manage and test. + +Create a new project directory in the `ec_examples` directory named `integration_project`. Give it a `Cargo.toml` file with the following content: + +```toml +# Integration Project +[package] +name = "integration_project" +version = "0.1.0" +edition = "2024" +resolver = "2" +description = "System-level integration sim wiring Battery, Charger, and Thermal" + + +[dependencies] +embedded-batteries-async = { workspace = true } +embassy-executor = { workspace = true } +embassy-time = { workspace = true } +embassy-sync = { workspace = true } +embassy-futures = { workspace = true } +embassy-time-driver = { workspace = true } +embassy-time-queue-utils = { workspace = true } + +embedded-services = { workspace = true } +battery-service = { workspace = true } +embedded-sensors-hal-async = {workspace = true} + +ec_common = { path = "../ec_common"} +mock_battery = { path = "../battery_project/mock_battery", default-features = false} +mock_charger = { path = "../charger_project/mock_charger", default-features = false} +mock_thermal = { path = "../thermal_project/mock_thermal", default-features = false} + +static_cell = "2.1" +futures = "0.3" +heapless = "0.8" +crossterm = "0.27" + +[features] +default = ["std", "thread-mode"] +std = [] +thread-mode = [ + "mock_battery/thread-mode", + "mock_charger/thread-mode", + "mock_thermal/thread-mode" +] +noop-mode = [ + "mock_battery/noop-mode", + "mock_charger/noop-mode", + "mock_thermal/noop-mode" +] +``` + +Next, edit the `ec_examples/Cargo.toml` at the top level to add `integration_project` as a workspace member: + +```toml + members = [ + "battery_project/mock_battery", + "charger_project/mock_charger", + "thermal_project/mock_thermal", + "battery_charger_subsystem", + "integration_project", + "ec_common" +] +``` + +_As a reminder, the whole of `ec_examples/Cargo.toml` looks like this:_ + +```toml +# ec_examples/Cargo.toml +[workspace] +resolver = "2" +members = [ + "battery_project/mock_battery", + "charger_project/mock_charger", + "thermal_project/mock_thermal", + "battery_charger_subsystem", + "integration_project", + "ec_common" +] + +[workspace.dependencies] +embedded-services = { path = "embedded-services/embedded-service" } +battery-service = { path = "embedded-services/battery-service" } +embedded-batteries = { path = "embedded-batteries/embedded-batteries" } +embedded-batteries-async = { path = "embedded-batteries/embedded-batteries-async" } +embedded-cfu-protocol = { path = "embedded-cfu" } +embedded-usb-pd = { path = "embedded-usb-pd" } + +thermal-service = { path = "embedded-services/thermal-service" } +embedded-sensors-hal-async = { path = "embedded-sensors/embedded-sensors-async"} +embedded-fans-async = { path = "embedded-fans/embedded-fans-async"} + +embassy-executor = { path = "embassy/embassy-executor", features = ["arch-std", "executor-thread"], default-features = false } +embassy-time = { path = "embassy/embassy-time", features=["std"], default-features = false } +embassy-sync = { path = "embassy/embassy-sync", features = ["std"] } +embassy-futures = { path = "embassy/embassy-futures" } +embassy-time-driver = { path = "embassy/embassy-time-driver", default-features = false} +embassy-time-queue-utils = { path = "embassy/embassy-time-queue-utils" } +embedded-hal = "1.0" +embedded-hal-async = "1.0" +once_cell = "1.19" +static_cell = "2.1.0" +defmt = "1.0" +log = "0.4.27" +bitfield = "0.19.1" +bitflags = "1.0" +bitvec = "1.0" +cfg-if = "1.0" +chrono = "0.4.41" +tokio = { version = "1.45", features = ["full"] } +uuid = "1.0" +critical-section = {version = "1.0", features = ["std"] } +document-features = "0.2.11" +embedded-hal-nb = "1.0" +embedded-io = "0.6.1" +embedded-io-async = "0.6.1" +embedded-storage = "0.3.1" +embedded-storage-async = "0.4.1" +fixed = "1.0" +heapless = "0.8.0" +postcard = "1.0" +rand_core = "0.9.3" +serde = "1.0" +cortex-m = "0.7.7" +cortex-m-rt = "0.7.5" + +[patch.crates-io] +embassy-executor = { path = "embassy/embassy-executor" } +embassy-time = { path = "embassy/embassy-time" } +embassy-sync = { path = "embassy/embassy-sync" } +embassy-futures = { path = "embassy/embassy-futures" } +embassy-time-driver = { path = "embassy/embassy-time-driver" } +embassy-time-queue-utils = { path = "embassy/embassy-time-queue-utils" } +embedded-batteries-async = { path = "embedded-batteries/embedded-batteries-async" } + +# Lint settings for the entire workspace. +# We start with basic warning visibility, especially for upcoming Rust changes. +# Additional lints are listed here but disabled by default, since enabling them +# may trigger warnings in upstream submodules like `embedded-services`. +# +# To tighten enforcement over time, you can uncomment these as needed. +[workspace.lints.rust] +warnings = "warn" # Show warnings, but do not fail the build +future_incompatible = "warn" # Highlight upcoming breakage (future Rust versions) +# rust_2018_idioms = "warn" # Enforce idiomatic Rust style (may warn on legacy code) +# unused_crate_dependencies = "warn" # Detect unused deps — useful during cleanup +# missing_docs = "warn" # Require documentation for all public items +# unsafe_code = "deny" # Forbid use of `unsafe` entirely + +[patch.'https://github.com/embassy-rs/embassy'] +embassy-time = { path = "./embassy/embassy-time" } +embassy-time-driver = { path = "./embassy/embassy-time-driver" } +embassy-sync = { path = "./embassy/embassy-sync" } +embassy-executor = { path = "./embassy/embassy-executor" } +embassy-futures = { path = "./embassy/embassy-futures" } +``` + +Now we can get on with the changes to our existing code to make things ready for this integration, starting with defining some structures for configuration to give us parametric control of behavior and policy. + diff --git a/guide_book/src/how/ec/integration/10-controller_core.md b/guide_book/src/how/ec/integration/10-controller_core.md new file mode 100644 index 0000000..e89a821 --- /dev/null +++ b/guide_book/src/how/ec/integration/10-controller_core.md @@ -0,0 +1,413 @@ +# ControllerCore + +Now we are ready to implement the core of our integration, the `ControllerCore` structure and its associated tasks and trait implementations. This is where the bulk of our integration logic will reside. + +Our `ControllerCore` implementation will consist of four primary areas of concern: + +1. basic implementation of collected components +2. Controller trait implementation +3. spawned tasks, including listeners that accept messages +4. handlers that conduct the actions related to messages received. + +The first two of these are necessary to implement in order to create a minimally viable first test. + +Let's start out with the basic implementation of `contoller_core.rs` by starting with this code: +```rust +use mock_battery::mock_battery_controller::MockBatteryController; +use mock_charger::mock_charger_controller::MockChargerController; +use mock_thermal::mock_sensor_controller::MockSensorController; +use mock_thermal::mock_fan_controller::MockFanController; +use crate::config::ui_config::RenderMode; +use crate::system_observer::SystemObserver; +use crate::entry::{BatteryChannelWrapper, ChargerChannelWrapper, InteractionChannelWrapper, ThermalChannelWrapper}; + +use battery_service::controller::{Controller, ControllerEvent}; +use battery_service::device::{DynamicBatteryMsgs, StaticBatteryMsgs}; +use embassy_time::Duration; +use mock_battery::mock_battery::MockBatteryError; +use embedded_batteries_async::smart_battery::{ + SmartBattery, + ManufactureDate, SpecificationInfoFields, CapacityModeValue, CapacityModeSignedValue, + BatteryModeFields, BatteryStatusFields, + DeciKelvin, MilliVolts +}; + +use embedded_services::power::policy::charger::Device as ChargerDevice; // disambiguate from other device types +use embedded_services::power::policy::PowerCapability; +use embedded_services::power::policy::charger::PolicyEvent; +use embedded_services::power::policy::charger::ChargerResponseData; + +use embedded_sensors_hal_async::temperature::TemperatureThresholdSet; + +use ec_common::mutex::{Mutex, RawMutex}; +use crate::display_models::StaticValues; +use crate::events::{BusEvent, InteractionEvent}; +use ec_common::events::{ThermalEvent, ThresholdEvent}; +use embedded_services::power::policy::charger::{ChargerEvent, PsuState}; +use embassy_sync::channel::{Channel, Sender, Receiver, TrySendError}; + +use embassy_executor::Spawner; + +use embedded_batteries_async::charger::{Charger, MilliAmps}; +use embedded_services::power::policy::charger::{ + ChargeController,ChargerError +}; +use mock_charger::mock_charger::MockChargerError; + +const BUS_CAP: usize = 32; + +use crate::config::AllConfig; +use crate::state::{ChargerState, ThermalState, SimState}; + +#[allow(unused)] +pub struct ControllerCore { + // device components + pub battery: MockBatteryController, // controller tap is owned by battery service wrapper + pub charger: MockChargerController, + pub sensor: MockSensorController, + pub fan: MockFanController, + // for charger service + pub charger_service_device: &'static ChargerDevice, + + // comm busses + pub battery_channel: &'static BatteryChannelWrapper, // owned by setup and shared + pub charger_channel: &'static ChargerChannelWrapper, + pub thermal_channel: &'static ThermalChannelWrapper, + pub interaction_channel: &'static InteractionChannelWrapper, + + tx:Sender<'static, RawMutex, BusEvent, BUS_CAP>, + + // ui observer + pub sysobs: &'static SystemObserver, // owned by setup and shared + + // configuration + pub cfg: AllConfig, + + // state + pub sim: SimState, + pub therm: ThermalState, + pub chg: ChargerState + +} + +static BUS: Channel = Channel::new(); + +impl ControllerCore { + pub fn new( + battery: MockBatteryController, + charger: MockChargerController, + sensor: MockSensorController, + fan: MockFanController, + charger_service_device: &'static ChargerDevice, + battery_channel: &'static BatteryChannelWrapper, + charger_channel: &'static ChargerChannelWrapper, + thermal_channel: &'static ThermalChannelWrapper, + interaction_channel: &'static InteractionChannelWrapper, + sysobs: &'static SystemObserver, + ) -> Self + { + Self { + battery, charger, sensor, fan, + charger_service_device, + battery_channel, charger_channel, thermal_channel, interaction_channel, + tx: BUS.sender(), + sysobs, + cfg: AllConfig::default(), + sim: SimState::default(), + therm: ThermalState::default(), + chg: ChargerState::default() + } + } + + // === API for message senders === + /// No-await event emit + #[allow(unused)] + pub fn try_send(&self, evt: BusEvent) -> Result<(), TrySendError> { + self.tx.try_send(evt) + } + + /// Awaiting send for must-deliver events. + #[allow(unused)] + pub async fn send(&self, evt: BusEvent) { + self.tx.send(evt).await + } + + /// start event processing with a passed mutex + pub fn start(core_mutex: &'static Mutex, spawner: Spawner) { + + println!("In ControllerCore::start (fn={:p})", Self::start as *const ()); + } +} +``` + +Now, you will recall that we created `BatteryAdapter` as a structure implementing all the traits required for it to serve as the component registered for the Battery Service (via the `BatteryWrapper`), and that this implementation simply passed these traits along to this `ControllerCore` instance, so we must necessarily implement all those trait methods here in `ControllerCore` as well. Since we have our actual Battery object contained here, we can forward these in turn to that component, thus attaching it to the Battery Service. But along the way, we get the opportunity to "tap into" this relay and use this opportunity to conduct our integration business. + +Let's go ahead and implement these traits by adding this code to `controller_core.rs` now. +This looks long, but most of it is just pass-through to the underlying battery and charger components (remember how extensive the `SmartBatter`y traits are): + +```rust +// ================= traits ================== +impl embedded_batteries_async::smart_battery::ErrorType for ControllerCore +{ + type Error = MockBatteryError; +} + +impl SmartBattery for ControllerCore +{ + async fn temperature(&mut self) -> Result { + self.battery.temperature().await + } + + async fn voltage(&mut self) -> Result { + self.battery.voltage().await + } + + async fn remaining_capacity_alarm(&mut self) -> Result { + self.battery.remaining_capacity_alarm().await + } + + async fn set_remaining_capacity_alarm(&mut self, v: CapacityModeValue) -> Result<(), Self::Error> { + self.battery.set_remaining_capacity_alarm(v).await + } + + async fn remaining_time_alarm(&mut self) -> Result { + self.battery.remaining_time_alarm().await + } + + async fn set_remaining_time_alarm(&mut self, v: u16) -> Result<(), Self::Error> { + self.battery.set_remaining_time_alarm(v).await + } + + async fn battery_mode(&mut self) -> Result { + self.battery.battery_mode().await + } + + async fn set_battery_mode(&mut self, v: BatteryModeFields) -> Result<(), Self::Error> { + self.battery.set_battery_mode(v).await + } + + async fn at_rate(&mut self) -> Result { + self.battery.at_rate().await + } + + async fn set_at_rate(&mut self, _: CapacityModeSignedValue) -> Result<(), Self::Error> { + self.battery.set_at_rate(CapacityModeSignedValue::MilliAmpSigned(0)).await + } + + async fn at_rate_time_to_full(&mut self) -> Result { + self.battery.at_rate_time_to_full().await + } + + async fn at_rate_time_to_empty(&mut self) -> Result { + self.battery.at_rate_time_to_empty().await + } + + async fn at_rate_ok(&mut self) -> Result { + self.battery.at_rate_ok().await + } + + async fn current(&mut self) -> Result { + self.battery.current().await + } + + async fn average_current(&mut self) -> Result { + self.battery.average_current().await + } + + async fn max_error(&mut self) -> Result { + self.battery.max_error().await + } + + async fn relative_state_of_charge(&mut self) -> Result { + self.battery.relative_state_of_charge().await + } + + async fn absolute_state_of_charge(&mut self) -> Result { + self.battery.absolute_state_of_charge().await + } + + async fn remaining_capacity(&mut self) -> Result { + self.battery.remaining_capacity().await + } + + async fn full_charge_capacity(&mut self) -> Result { + self.battery.full_charge_capacity().await + } + + async fn run_time_to_empty(&mut self) -> Result { + self.battery.run_time_to_empty().await + } + + async fn average_time_to_empty(&mut self) -> Result { + self.battery.average_time_to_empty().await + } + + async fn average_time_to_full(&mut self) -> Result { + self.battery.average_time_to_full().await + } + + async fn charging_current(&mut self) -> Result { + self.battery.charging_current().await + } + + async fn charging_voltage(&mut self) -> Result { + self.battery.charging_voltage().await + } + + async fn battery_status(&mut self) -> Result { + self.battery.battery_status().await + } + + async fn cycle_count(&mut self) -> Result { + self.battery.cycle_count().await + } + + async fn design_capacity(&mut self) -> Result { + self.battery.design_capacity().await + } + + async fn design_voltage(&mut self) -> Result { + self.battery.design_voltage().await + } + + async fn specification_info(&mut self) -> Result { + self.battery.specification_info().await + } + + async fn manufacture_date(&mut self) -> Result { + self.battery.manufacture_date().await + } + + async fn serial_number(&mut self) -> Result { + self.battery.serial_number().await + } + + async fn manufacturer_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.manufacturer_name(v).await + } + + async fn device_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_name(v).await + } + + async fn device_chemistry(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_chemistry(v).await + } +} + + +// helper works for Vec, &Vec, &[u8], [u8; N], heapless::String, etc. +fn to_string_lossy>(b: B) -> String { + String::from_utf8_lossy(b.as_ref()).into_owned() +} + +// Implement the same trait the wrapper expects. +impl Controller for ControllerCore { + + + type ControllerError = MockBatteryError; + + async fn initialize(&mut self) -> Result<(), Self::ControllerError> { + Ok(()) + } + + async fn get_static_data(&mut self) -> Result { + println!("🥳 >>>>> get_static_data has been called!!! <<<<<<"); + self.battery.get_static_data().await + + } + + async fn get_dynamic_data(&mut self) -> Result { + println!("🥳 >>>>> get_dynamic_data has been called!!! <<<<<<"); + self.battery.get_dynamic_data().await + } + + async fn get_device_event(&mut self) -> ControllerEvent { + println!("🥳 >>>>> get_device_event has been called!!! <<<<<<"); + core::future::pending().await + } + + async fn ping(&mut self) -> Result<(), Self::ControllerError> { + println!("🥳 >>>>> ping has been called!!! <<<<<<"); + self.battery.ping().await + + } + + fn get_timeout(&self) -> Duration { + println!("🥳 >>>>> get_timeout has been called!!! <<<<<<"); + self.battery.get_timeout() + } + + fn set_timeout(&mut self, duration: Duration) { + println!("🥳 >>>>> set_timeout has been called!!! <<<<<<"); + self.battery.set_timeout(duration) + } +} + +// --- charger --- +impl embedded_batteries_async::charger::ErrorType for ControllerCore +{ + type Error = MockChargerError; +} + +impl Charger for ControllerCore +{ + fn charging_current( + &mut self, + requested_current: MilliAmps, + ) -> impl core::future::Future> { + self.charger.charging_current(requested_current) + } + + fn charging_voltage( + &mut self, + requested_voltage: MilliVolts, + ) -> impl core::future::Future> { + self.charger.charging_voltage(requested_voltage) + } +} + +impl ChargeController for ControllerCore +{ + type ChargeControllerError = ChargerError; + + fn wait_event(&mut self) -> impl core::future::Future { + async move { ChargerEvent::Initialized(PsuState::Attached) } + } + + fn init_charger( + &mut self, + ) -> impl core::future::Future> { + self.charger.init_charger() + } + + fn is_psu_attached( + &mut self, + ) -> impl core::future::Future> { + self.charger.is_psu_attached() + } + + fn attach_handler( + &mut self, + capability: PowerCapability, + ) -> impl core::future::Future> { + self.charger.attach_handler(capability) + } + + fn detach_handler( + &mut self, + ) -> impl core::future::Future> { + self.charger.detach_handler() + } + + fn is_ready( + &mut self, + ) -> impl core::future::Future> { + self.charger.is_ready() + } +} +``` + +By adding these traits we satisfy the interface requirements for a battery-service / Battery Controller implementation and also as a Charger Controller. We have `println!` output in place to tell us when the Battery Controller traits are called from the battery-service. These will make a good first test. + +We have almost all the parts we need to run a simple test to see if things are wired up correctly. We just need to add a few final items to get everything started. Let's do that next. diff --git a/guide_book/src/how/ec/integration/11-first_tests.md b/guide_book/src/how/ec/integration/11-first_tests.md new file mode 100644 index 0000000..c012355 --- /dev/null +++ b/guide_book/src/how/ec/integration/11-first_tests.md @@ -0,0 +1,195 @@ +# First Tests + +Now let's go back to `entry.rs` and add a few more imports we will need: +```rust +use embassy_executor::Spawner; +use crate::display_models::Thresholds; +use mock_battery::virtual_battery::VirtualBatteryState; +use crate::events::RenderMode; +use crate::events::DisplayEvent; +use crate::events::InteractionEvent; +use crate::system_observer::SystemObserver; +// use crate::display_render::display_render::DisplayRenderer; + +// Task imports +use crate::setup_and_tap::{ + setup_and_tap_task +}; +``` +And now we want to add the entry point that is called by `main()` here (in `entry.rs`): +```rust +#[embassy_executor::task] +pub async fn entry_task_interactive(spawner: Spawner) { + println!("🚀 Interactive mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + // spawner.spawn(interaction_task(shared.interaction_channel)).unwrap(); + // spawner.spawn(render_task(shared.display_channel)).unwrap(); + +} +``` +Now we need to create and connect the `SystemObserver` within `entry.rs`. + +Below the other static allocations, add this line: +```rust + +static SYS_OBS: StaticCell = StaticCell::new(); + +``` +Uncomment this line to expose the observer property we are about to create +```rust +pub struct Shared { + // pub observer: &'static SystemObserver, + pub battery_channel: &'static BatteryChannelWrapper, +``` +and uncomment the creation of this in `init_shared()`: +```rust + // let observer = SYS_OBS.init(SystemObserver::new( + // Thresholds::new(), + // v_nominal_mv, + // display_channel + // )); +``` +as well as the reference to `observer` in the creation of `Shared` below that: +```rust + SHARED.get_or_init(|| SHARED_CELL.init(Shared { + // observer, + battery_channel, +``` + +Great! We are almost ready for our first test run. We just need to add some start up tasks to complete the work +in `setup_and_tap.rs`: + +Add these tasks: +```rust +// this will move ownership of ControllerTap to the battery_service, which will utilize the battery traits +// to call messages that we intercept ('tap') and thus can access the other components for messaging and simulation. +#[embassy_executor::task] +pub async fn battery_wrapper_task(wrapper: &'static mut Wrapper<'static, BatteryAdapter>) { + wrapper.process().await; +} + +#[embassy_executor::task] +pub async fn battery_start_task() { + use battery_service::context::{BatteryEvent, BatteryEventInner}; + use battery_service::device::DeviceId; + + println!("🥺 Doing battery service startup -- DoInit followed by PollDynamicData"); + + // 1) initialize (this will Ping + UpdateStaticCache, then move to Polling) + let init_resp = battery_service::execute_event(BatteryEvent { + device_id: DeviceId(BATTERY_DEV_NUM), + event: BatteryEventInner::DoInit, + }).await; + + println!("battery-service DoInit -> {:?}", init_resp); + + // 2) get Static data first + let static_resp = battery_service::execute_event(BatteryEvent { + device_id: DeviceId(BATTERY_DEV_NUM), + event: BatteryEventInner::PollStaticData, + }).await; + if static_resp.is_err() { + eprintln!("Polling loop PollStaticData call to battery service failure!"); + } + + let delay:Duration = Duration::from_secs(3); + let interval:Duration = Duration::from_millis(250); + + embassy_time::Timer::after(delay).await; + + loop { + // 3) now poll dynamic (valid only in Polling) + let dyn_resp = battery_service::execute_event(BatteryEvent { + device_id: DeviceId(BATTERY_DEV_NUM), + event: BatteryEventInner::PollDynamicData, + }).await; + if let Err(e) = &dyn_resp { + eprintln!("Polling loop PollDynamicData call to battery service failure! (pretty) {e:#?}"); + } + embassy_time::Timer::after(interval).await; + } + +} +``` +Starting the `battery_wrapper_task` is what binds our battery controller to the battery-service where it awaits command messages to begin its orchestration. We kick this off in `battery_start_task` by giving it the expected sequence of starting messages, placing it into the _polling_ mode where we can continue the pump to receive repeated dynamic data reports. + +### Including modules +If you haven't already, be sure to include the new modules in `main.rs`. The set of modules named here should include: +```rust +mod config; +mod policy; +mod model; +mod state; +mod events; +mod entry; +mod setup_and_tap; +mod controller_core; +mod display_models; +mod battery_adapter; +mod system_observer; +``` + + +At this point, you should be able to do a `cargo check` and get a successful build without errors -- you'll get a lot of warnings because there are a number of unused imports and references we haven't attached yet, but you can ignore those for now. + +If you run the program with `cargo run`, you should see this output: +``` +🚀 Interactive mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🥳 >>>>> get_timeout has been called!!! <<<<<< +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start (fn=0x7ff6425f9860) +spawning charger_policy_event_task +spawning controller_core_task +spawning start_charger_task +spawning integration_listener_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +🥳 >>>>> get_timeout has been called!!! <<<<<< +battery-service DoInit -> Ok(Ack) +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> get_static_data has been called!!! <<<<<< +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> get_dynamic_data has been called!!! <<<<<< +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> get_dynamic_data has been called!!! <<<<<< +``` +with the last few lines repeating endlessly. Press `ctrl-c` to exit. + +Congratulations! This means that the scaffolding is all in place and ready for the continuation of the implementation. + +Let's pause here to review what is actually happening at this point. + +### Review of operation so far +1. `main()` calls `entry_task_interactive()`, which initializes the shared handles and spawns the `setup_and_tap_task()`. +2. `setup_and_tap_task()` initializes embedded-services, spawns the battery service task, constructs and registers the mock devices and controllers, and finally spawns the `ControllerCore::start()` task. +3. `ControllerCore::start()` initializes the controller core, spawns the charger policy event task, the controller core task, the start charger task, and the integration listener task. +4. Meanwhile, back in `entry_task_interactive()`, after spawning `setup_and_tap_task()`, it waits for the battery fuel service to signal that it is ready, which happens at the end of `setup_and_tap_task()`. +5. The `battery_start_task()` is spawned as part of `setup_and_tap_task ()`, which initializes the battery service by sending it a `DoInit` event, followed by a `PollStaticData` event, and then enters a loop where it continuously sends `PollDynamicData` events to the battery service at regular intervals. This is what drives the periodic updates of battery data in our integration. +6. The battery service, upon receiving the `DoInit` event, calls the `ping()` and `get_timeout()` methods of our `BatteryAdapter`, which in turn call into the `ControllerCore` to handle these requests. The battery service then transitions to the polling state. +7. The `PollStaticData` and `PollDynamicData` events similarly call into the `BatteryAdapter`, which forwards these calls to the `ControllerCore`, which will eventually handle these requests and return the appropriate data to the battery service. +8. The `ControllerCore` also has tasks running that listen for charger policy events and other integration events, although these are not yet fully implemented. + +As we see here, the operational flow is driven through the battery service's polling mechanism, where we we tap the `get_dynamic_data()` calls to access the `ControllerCore` and shared comm channels to facilitate the integration of the various components to work together. + +To do that, we will next implement the listeners and handlers within the ControllerCore to respond to these calls and to manage the interactions between the battery, charger, sensor, and fan components. + diff --git a/guide_book/src/how/ec/integration/12-tasks_and_listeners.md b/guide_book/src/how/ec/integration/12-tasks_and_listeners.md new file mode 100644 index 0000000..b6b60c3 --- /dev/null +++ b/guide_book/src/how/ec/integration/12-tasks_and_listeners.md @@ -0,0 +1,261 @@ +# Tasks, Listeners, and Handlers + +So, our first test shows us our nascent scaffolding is working. We see the the `println!` output from our `ControllerCore` tapped trait methods, and we see the pump continue to run through tap point of `get_dynamic_data()`. + +We can use this tap point to orchestrate interaction with the other components. But before we do that, we need to establish an independent way to communicate with these components through our message channels. Although we are within the ControllerCore context and have direct access to the component methods, we want to preserve the modularity of our components and keep them isolated from each other. Messages allow us to do this without locking ourselves into a tightly-coupled design. + +>---- +> ### Rule of Thumb -- locking the core +> - __Lock once, copy out, unlock fast.__ Read all the field you need locally then release the lock before computation or I/O. +> - __Never hold a lock across `.await`.__ Extract data you'll need, drop the guard, _then_ `await`. +> - __Prefer one short lock over many tiny locks.__ It reduces contention and avoids inconsistent snapshots. +> +> --- + +Let's start with the general listening task of the `ControllerCore`. This task will listen for messages on the channels we have established, and then forward these messages to the appropriate handlers. + +Add this to `controller_core.rs`: + +```rust +// ==== General event listener task ===== +#[embassy_executor::task] +pub async fn controller_core_task(receiver:Receiver<'static, RawMutex, BusEvent, BUS_CAP>, core_mutex: &'static Mutex) { + + loop { + let event = receiver.receive().await; + match event { + BusEvent::Charger(e) => handle_charger(core_mutex, e).await, + BusEvent::Thermal(e) => handle_thermal(core_mutex, e).await, + BusEvent::ChargerPolicy(_) => handle_charger_policy(core_mutex, event).await, + } + } +} +``` +and add the spawn for this task in the `start()` method of `ControllerCore`: + +```rust + /// start event processing with a passed mutex + pub fn start(core_mutex: &'static Mutex, spawner: Spawner) { + + println!("In ControllerCore::start()"); + + println!("spawning controller_core_task"); + if let Err(e) = spawner.spawn(controller_core_task(BUS.receiver(), core_mutex)) { + eprintln!("spawn controller_core_task failed: {:?}", e); + } + } +``` + +This establishes a general listener task that will receive messages from the bus and forward them to specific handlers. We will define these handlers next. Add these handler functions to `controller_core.rs`: + +```rust +async fn handle_charger(core_mutex: &'static Mutex, event: ChargerEvent) { + + let device = { + let core = core_mutex.lock().await; + core.charger_service_device + }; + + match event { + ChargerEvent::Initialized(PsuState::Attached) => { + } + + ChargerEvent::PsuStateChange(PsuState::Attached) => { + println!(" ☄ attaching charger"); + let _ = device.execute_command(PolicyEvent::InitRequest).await; // let the policy attach and ramp per latest PowerConfiguration. + } + + ChargerEvent::PsuStateChange(PsuState::Detached) | + ChargerEvent::Initialized(PsuState::Detached) => { + println!(" ✂ detaching charger"); + let zero_cap = PowerCapability {voltage_mv: 0, current_ma: 0}; + let _ = device.execute_command(PolicyEvent::PolicyConfiguration(zero_cap)).await; // should detach with this. + } + + ChargerEvent::Timeout => { + println!("⏳ Charger Timeout occurred"); + } + ChargerEvent::BusError => { + println!("❌ Charger Bus error occurred"); + } + } +} + +async fn handle_charger_policy(core_mutex: &'static Mutex, evt: BusEvent) { + match evt { + BusEvent::ChargerPolicy(cap)=> { + + // Treat current==0 as a detach request + if cap.current_ma == 0 { + let mut core = core_mutex.lock().await; + let _ = core.charger.detach_handler().await; + let _ = core.charger.charging_current(0).await; + } else { + let mut core = core_mutex.lock().await; + // Make sure we’re “attached” at the policy layer + let _ = core.charger.attach_handler(cap).await; + + // Program voltage then current; the mock should update its internal state + let _ = core.charger.charging_voltage(cap.voltage_mv).await; + let _ = core.charger.charging_current(cap.current_ma).await; + } + + // echo what the mock reports now + if is_log_mode(core_mutex).await { + let core = core_mutex.lock().await; + let now = { core.charger.charger.state.lock().await.current() }; + println!("🔌 Applied {:?}; charger now reports {} mA", cap, now); + } + } + _ => {} + } +} + +async fn handle_thermal(core_mutex: &'static Mutex, evt: ThermalEvent) { + match evt { + ThermalEvent::TempSampleC100(cc) => { + let temp_c = cc as f32 / 100.0; + { + let mut core = core_mutex.lock().await; + core.sensor.sensor.set_temperature(temp_c); + } + } + + ThermalEvent::Threshold(th) => { + match th { + ThresholdEvent::OverHigh => println!(" ⚠🔥 running hot"), + _ => {} + } + } + + ThermalEvent::CoolingRequest(req) => { + let mut core = core_mutex.lock().await; + let policy = core.cfg.policy.thermal.fan_policy; + let cur_level = core.therm.fan_level; + let (res, _rpm) = core.fan.handle_request(cur_level, req, &policy).await.unwrap(); + core.therm.fan_level = res.new_level; + } + } +} +``` +We can see that these handlers are fairly straightforward. It is here that we _do_ call into the integrated component internals, _after_ receiving the message that directs the action. Each handler locks the `ControllerCore` mutex, and then call the appropriate methods on the components. The implementation of these actions is very much like what we have done in the previous integrations. One notable difference, however, is in `handle_charger` we call upon the registered `charger_service_device` to execute the `PolicyEvent` commands. We do this to take advantage of the charger policy handling that is built into the embedded-services charger device. This allows us to offload some of the policy management to the embedded-services layer, which is a good thing. In previous integrations, we chose to implement this ourselves. Both approaches are valid, but using the built-in policy handling allows for a predictable and repeatable behavior that is consistent with other embedded-services based implementations. + +## The Charger Task and Charger Policy Task +On that subject, it's not enough to just call `device_command` on the charger device when we receive a `ChargerEvent`. We also need to start the charger service and have a task that listens for charger policy events and sends those to the charger device. This is because the charger policy events may be generated from other parts of the system, such as the battery service or the thermal management system, and we need to have a dedicated task to handle these events. + +Let's add those two tasks now: +```rust +// helper for log mode check +pub async fn is_log_mode(core_mutex: &'static Mutex) -> bool { + let core = core_mutex.lock().await; + core.cfg.ui.render_mode == RenderMode::Log +} + +#[embassy_executor::task] +async fn start_charger_task(core_mutex: &'static Mutex) { + + let p = is_log_mode(core_mutex).await; + let device = { + let core = core_mutex.lock().await; + core.charger_service_device + }; + + if p {println!("start_charger_task");} + if p {println!("waiting for yield");} + // give a tick to start before continuing to avoid possible race + embassy_futures::yield_now().await; + + // Now issue commands and await responses + if p {println!("issuing CheckReady and InitRequest to charger device");} + let _ = device.execute_command(PolicyEvent::CheckReady).await; + let _ = device.execute_command(PolicyEvent::InitRequest).await; +} + +// ==== Charger subsystem event listener ==== +#[embassy_executor::task] +pub async fn charger_policy_event_task(core_mutex: &'static Mutex) { + + let p = is_log_mode(core_mutex).await; + let device = { + let core = core_mutex.lock().await; + core.charger_service_device + }; + + loop { + match device.wait_command().await { + PolicyEvent::CheckReady => { + if p {println!("Charger PolicyEvent::CheckReady received");} + let res = { + let mut core = core_mutex.lock().await; + core.charger.is_ready().await + } + .map(|_| Ok(ChargerResponseData::Ack)) + .unwrap_or_else(|_| Err(ChargerError::Timeout)); + device.send_response(res).await; + } + PolicyEvent::InitRequest => { + if p {println!("Charger PolicyEvent::InitRequest received");} + let res = { + let mut core = core_mutex.lock().await; + core.charger.init_charger().await + } + .map(|_| Ok(ChargerResponseData::Ack)) + .unwrap_or_else(|_| Err(ChargerError::BusError)); + device.send_response(res).await; + } + PolicyEvent::PolicyConfiguration(cap) => { + if p {println!("Charger PolicyEvent::PolicyConfiguration received {:?}", cap);} + device.send_response(Ok(ChargerResponseData::Ack)).await; // ack so caller can continue + let core = core_mutex.lock().await; + if core.try_send(BusEvent::ChargerPolicy(cap)).is_err() { + eprintln!("⚠️ Dropped ChargerPolicy event (bus full)"); + } + } + } + } +} +``` + + +> --- +> ### Rule of thumb --`send` vs `try_send` +> - Use `send` when in an async context for must-deliver events (rare, low-rate control/path): it awaits and guarantees delivery order. +> - Use `try_send` for best effort or high-rate events, or from a non-async context. It returns immediately. Check the error for failure if the bus is full. +> - If dropping is unacceptable but backpressure is possible, keep retrying +> - Log drops from `try_send` to catch buffer capacity issues early on. +> +> ---- + + +You may have noticed that we also snuck in a helper function `is_log_mode()` to check if we are in log mode. This is used to control the verbosity of the output from these tasks. This will make more sense once we have the display and interaction system in place. + +We also need to spawn these tasks in the `start()` method of `ControllerCore`. Add these spawns to the `start()` method: + +```rust + println!("spawning start_charger_task"); + if let Err(e) = spawner.spawn(start_charger_task(core_mutex)) { + eprintln!("spawn start_charger_task failed: {:?}", e); + } + println!("spawning charger_policy_event_task"); + if let Err(e) = spawner.spawn(charger_policy_event_task(core_mutex)) { + eprintln!("spawn charger_policy_event_task failed: {:?}", e); + } +``` + +### Starting values for thermal policy +Our thermal policy respects temperature thresholds to determine when to request cooling actions. We have established these thresholds in the configuration, but we need to set them into action before we begin. We can do this at the top of our `controller_core_task()` function, before we enter the main loop: + +```rust + // set initial temperature thresholds + { + let mut core = core_mutex.lock().await; + let lo_temp_threshold = core.cfg.policy.thermal.temp_low_on_c; + let hi_temp_threshold = core.cfg.policy.thermal.temp_high_on_c; + if let Err(e) = core.sensor.set_temperature_threshold_low(lo_temp_threshold) { eprintln!("temp low set failed: {e:?}"); } + if let Err(e) = core.sensor.set_temperature_threshold_high(hi_temp_threshold) { eprintln!("temp high set failed: {e:?}"); } + } +``` +We do this inside of a block to limit the scope of the mutex lock. This is a good practice to avoid holding locks longer than necessary. + + +Now the handling for charger and thermal events are in place. Now we can begin to implement the integration logic that binds these components together. diff --git a/guide_book/src/how/ec/integration/13-integration_logic.md b/guide_book/src/how/ec/integration/13-integration_logic.md new file mode 100644 index 0000000..9c4b38c --- /dev/null +++ b/guide_book/src/how/ec/integration/13-integration_logic.md @@ -0,0 +1,74 @@ +# Integration Logic + +The `get_dynamic_data()` method is our tap point for integration logic. However, for code organization if nothing else, we will be placing all the code for this into a new file `integration_logic.rs` and calling into it from the `get_dynamic_data()` interception point. + +Create `integration_logic.rs` and give it this content to start for now: + +```rust +use battery_service::controller::Controller; +use battery_service::device::DynamicBatteryMsgs; +use crate::controller_core::ControllerCore; +use mock_battery::mock_battery::MockBatteryError; + + +pub async fn integration_logic(core: &mut ControllerCore) -> Result { + let dd = core.battery.get_dynamic_data().await?; + println!("integration_logic: got dynamic data: {:?}", dd); + Ok(dd) +} +``` + +add this module to `main.rs`: + +```rust +mod integration_logic; +``` + +Now, modify the `get_dynamic_data()` method in `controller_core.rs` to call into this new function: + +```rust + async fn get_dynamic_data(&mut self) -> Result { + println!("ControllerCore: get_dynamic_data() called"); + crate::integration_logic::integration_logic(self).await + } +``` +And while we are in the area, let's comment out the `println!` statement for the `get_timeout()` trait method. We know that the battery-service calls this frequently to get the timeout duration, but we don't need to see that in our output every time: +```rust + fn get_timeout(&self) -> Duration { + // println!("🥳 >>>>> get_timeout has been called!!! <<<<<<"); + self.battery.get_timeout() + } +``` + +If we run the program now with `cargo run`, we should see output like this: +``` +🚀 Interactive mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start() +spawning controller_core_task +spawning start_charger_task +spawning charger_policy_event_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +battery-service DoInit -> Ok(Ack) +🥳 >>>>> get_static_data has been called!!! <<<<<< +ControllerCore: get_dynamic_data() called +integration_logic: got dynamic data: DynamicBatteryMsgs { max_power_mw: 0, sus_power_mw: 0, full_charge_capacity_mwh: 4800, remaining_capacity_mwh: 4800, relative_soc_pct: 100, cycle_count: 0, voltage_mv: 4200, max_error_pct: 1, battery_status: 0, charging_voltage_mv: 0, charging_current_ma: 0, battery_temp_dk: 2982, current_ma: 0, average_current_ma: 0 } +ControllerCore: get_dynamic_data() called +integration_logic: got dynamic data: DynamicBatteryMsgs { max_power_mw: 0, sus_power_mw: 0, full_charge_capacity_mwh: 4800, remaining_capacity_mwh: 4800, relative_soc_pct: 100, cycle_count: 0, voltage_mv: 4200, max_error_pct: 1, battery_status: 0, charging_voltage_mv: 0, charging_current_ma: 0, battery_temp_dk: 2982, current_ma: 0, average_current_ma: 0 } +``` +with the data dump from `get_dynamic_data()` repeated on each poll. + +Before we start to get involved in the details of the integration logic, let's pivot to the display and interaction side of things. We will need to have those pieces in place to be able to see the results of our integration logic as we develop it. diff --git a/guide_book/src/how/ec/integration/14-display_rendering.md b/guide_book/src/how/ec/integration/14-display_rendering.md new file mode 100644 index 0000000..51332e0 --- /dev/null +++ b/guide_book/src/how/ec/integration/14-display_rendering.md @@ -0,0 +1,498 @@ +# Display Rendering + +Earlier on, we introduced the `SystemObserver` structure, which is responsible for observing and reporting on the state of the system. One of its key features is the ability to render a display output, which we can implement in various ways, from a simple console output to a more complex graphical interface or a test reporting system. + +The mechanism we will use for this will be embodied in a structure we will call `DisplayRenderer`. This structure will be responsible for taking the data from the `SystemObserver` and rendering it in a way that is useful for our purposes. + +## DisplayRenderer +Because we will have more than one implementation of `DisplayRenderer`, we will define a trait that all implementations must satisfy. This trait will define the methods that must be implemented by any structure that wishes to be a `DisplayRenderer`. + +create a new folder in the `src` directory called `display_render`, and within that folder create a new file called `mod.rs`. In `mod.rs`, add the following: + +```rust +// display_render +pub mod display_render; +pub mod log_render; +pub mod in_place_render; +``` +Then, in this display_render folder, create empty files for `display_render.rs`, `log_render.rs`, and `in_place_render.rs`. + +In `display_render.rs`, we will define the `DisplayRenderer` trait and some helper methods that address common rendering tasks and display mode switching. + +```rust +use crate::events::RenderMode; +use crate::display_models::{DisplayValues, InteractionValues, StaticValues}; +use crate::display_render::in_place_render::InPlaceBackend; +use crate::display_render::log_render::LogBackend; +use crate::events::DisplayEvent; +use crate::entry::DisplayChannelWrapper; + + +// Define a trait for the interface for a rendering backend +pub trait RendererBackend : Send + Sync { + fn on_enter(&mut self, _last: Option<&DisplayValues>) {} + fn on_exit(&mut self) {} + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues); + fn render_static(&mut self, sv: &StaticValues); +} + +// ---- the renderer that can hot-swap backends ---- + +pub struct DisplayRenderer { + backend: Box, + mode: RenderMode, + last_frame: Option, +} + +impl DisplayRenderer { + pub fn new(initial: RenderMode) -> Self { + let mut me = Self { + backend: Self::make_backend(initial), + mode: initial, + last_frame: None + }; + me.backend.on_enter(None); + me + } + + fn make_backend(mode: RenderMode) -> Box { + match mode { + RenderMode::InPlace => Box::new(InPlaceBackend::new()), + RenderMode::Log => Box::new(LogBackend::new()), + } + } + + pub fn set_mode(&mut self, mode: RenderMode) { + if self.mode == mode { return; } + self.backend.on_exit(); + self.backend = Self::make_backend(mode); + self.backend.on_enter(self.last_frame.as_ref()); + self.mode = mode; + } + + pub fn toggle_mode(&mut self) { + if self.mode == RenderMode::InPlace { + self.set_mode(RenderMode::Log); + } else { + self.set_mode(RenderMode::InPlace); + } + } + + pub fn quit(&mut self) { + self.backend.on_exit(); + std::process::exit(0) + } + + + + pub async fn run(&mut self, display_rx: &'static DisplayChannelWrapper) -> ! { + loop { + match display_rx.receive().await { + DisplayEvent::Update(dv, ia) => { + // println!("Display update received {:?}", dv); + self.backend.render_frame(&dv, &ia); + self.last_frame = Some(dv); + }, + DisplayEvent::Static(sv) => { + // println!("Static update received {:?}", sv); + self.backend.render_static(&sv); + }, + DisplayEvent::ToggleMode => { + self.toggle_mode(); + }, + DisplayEvent::Quit => { + self.quit(); + } + } + } + } +} + +// common helper +pub fn time_fmt_from_ms(ms: f32) -> String { + let total_secs = (ms / 1000.0).floor() as u64; // floor = truncation + let m = total_secs / 60; + let s = total_secs % 60; + format!("{:02}:{:02}", m, s) +} +``` + +As you see, the `DisplayRenderer` offloads the actual rendering to a `RendererBackend`, which is a trait object that can be swapped out at runtime. The `DisplayRenderer` manages the current mode and handles events from the `DisplayChannelWrapper` through its `run()` method task. + +> ### Why traits for the renderer? +> Using a `RendererBackend` trait gives us: +> - __Hot-swappable backends__ - The `DisplayRenderer` pushes the same events into any of the implemented backends (`Log`, `InPlace`, `IntegrationTest`), so choice of rendering becomes an injection choice, not a refactor. +> - __Clean Testing__ - The `IntegrationTest` backend will consume the exact same UI pipe as the interactive designs, so tests are more easily able to exercise the true event flow. +> - __Tighter Coupling Where it belongs__ - `SystemObserver` doesn't know or care about ANSI control code or log formatting. That bit of implementation detail lives entirely within the individual renderers. +> - __Smaller and simpler than generics__ - A boxed trait object that can be injected avoids monomorphization bloat and keeps the API stable. +> - __Single Responsibility__ - Backends implement a small surface keeping the respective code cohesive and easier to reason over. +> +> ---- + +```mermaid +flowchart LR + IChan[InteractionChannel] --> Obs[SystemObserver] + Obs --> DChan[DisplayChannel] + DChan --> RSel[DisplayRenderer] + + subgraph Backends + direction TB + Log[Log renderer] + InPlace[InPlace renderer] + Test[IntegrationTest renderer] + end + + RSel --> Log + RSel --> InPlace + RSel --> Test +``` + +We later will create two implementations of `RendererBackend`: `InPlaceBackend` and `LogBackend` (ultimately, we will add a third for test reporting), but we'll start with a simple `LogBackend` that just logs the display updates to the console first. + +In `log_render.rs`, add the following: + +```rust + +use crate::display_render::display_render::{RendererBackend, time_fmt_from_ms}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; +use embassy_time::Instant; + +pub struct LogBackend; +impl LogBackend { pub fn new() -> Self { Self } } +impl RendererBackend for LogBackend { + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues) { + let (speed_number, speed_multiplier) = ia.get_speed_number_and_multiplier(); + let time_str = time_fmt_from_ms(dv.sim_time_ms); + let rt_ms = Instant::now().as_millis(); + println!( + "[{}]({} {}) {} - SOC {:>5.1}% | Draw {:>5.1}W | Chg {:>5.1}W | Net {:>+5.1}W | T {:>4.1}°C | Fan L{} {}% {}rpm", + rt_ms, speed_number, speed_multiplier, time_str, + dv.soc_percent, dv.draw_watts, dv.charge_watts, dv.net_watts, dv.temp_c, dv.fan_level, + dv.fan_percent, dv.fan_rpm + ); + } + fn render_static(&mut self, sv: &StaticValues) { + println!("{} {} #{}, {} mWh, {} mV [{}]", sv.battery_mfr, sv.battery_name, sv.battery_serial, sv.battery_dsgn_cap_mwh, sv.battery_dsgn_voltage_mv, sv.battery_chem); + } +} + +``` +This `LogBackend` simply prints the display updates to the console in a formatted manner. It also prints static information about the battery when it receives a `Static` event. + +We will also create a non-functional stub for now for the `InPlaceBackend` in `in_place_render.rs`, so that we can compile and run our code without errors. We will implement the actual in-place rendering later. +```rust +use crate::display_render::display_render::{RendererBackend, time_fmt_from_ms}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; + +pub struct InPlaceBackend; +impl InPlaceBackend { pub fn new() -> Self { Self } } +impl RendererBackend for InPlaceBackend { + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues) { + } + fn render_static(&mut self, sv: &StaticValues) { + } +} +``` + +### Adding the display_render module +Now, we need to add the `display_render` module to our project. In `main.rs`, add the following line to include the new module: + +```rust +mod display_render; +``` + +## Implementing the Display Task +The `SystemObserver` will send `DisplayEvent`s to the `DisplayRenderer` through a channel. The `run` method of `DisplayRenderer` is designed to be run as an async task that continuously listens for events and processes them. So, we need to create a task that will instantiate a `DisplayRenderer` and run it. We will do this in `entry.rs`. + +Open `entry.rs` and look at the `entry_task_interactive` startup function. From our previous work, there is likely a commented-out line that spawns a `render_task`. Uncomment that line (or add it if it's not there): + +```rust + spawner.spawn(render_task(shared.display_channel)).unwrap(); +``` +Then, add the `render_task` function to `entry.rs`, to call the `DisplayEvent` listener in `DisplayRenderer`: + +```rust +#[embassy_executor::task] +pub async fn render_task(rx: &'static DisplayChannelWrapper) { + let mut r = DisplayRenderer::new(RenderMode::Log); + r.run(rx).await; +} +``` +and add the necessary import at the top of `entry.rs`: + +```rust +use crate::display_render::display_render::DisplayRenderer; +``` + +Our display is driven by `DisplayEvent` messages. These events are sent by the `SystemObserver` when it has new data to display or when the display mode needs to change. There are separate messages for sending static data and for sending dynamic updates. The updates for dynamic data will only be rendered when there is a change in the data, to avoid unnecessary output. + +### Signaling static data +In our output we see that `get_static_data()` is called only once at startup, so we can start by collecting and sending the static display data to `SystemObserver` so that it can forward it to the `DisplayRenderer`. + +In `ControllerCore`, find the `get_static_data()` method and modify it like so: +```rust + async fn get_static_data(&mut self) -> Result { + let sd = self.battery.get_static_data().await?; + + let mfr = to_string_lossy(&sd.manufacturer_name); + let name = to_string_lossy(&sd.device_name); + let chem = to_string_lossy(&sd.device_chemistry); + let serial = serial_bytes_to_string(&sd.serial_num); + + let cap_mwh: u32 = sd.design_capacity_mwh; + let volt_mv: u16 = sd.design_voltage_mv; + + self.sysobs.set_static(StaticValues { + battery_mfr: mfr, + battery_name: name, + battery_chem: chem, + battery_serial: serial, + battery_dsgn_cap_mwh: cap_mwh, + battery_dsgn_voltage_mv: volt_mv + }).await; + Ok(sd) + } +``` +Above the Controller trait implementations, you will find the existing helper function `to_string_lossy()`, which is used here. Add the following helper to correctly convert the byte array that comprises the battery serial number into a string for display: +```rust +// helper to convert serial number bytes to a string +fn serial_bytes_to_string(serial: &[u8]) -> String { + match serial { + // Common case from MockBatteryController: [0, 0, hi, lo] + [0, 0, hi, lo] => u16::from_be_bytes([*hi, *lo]).to_string(), + // Gracefully handle a plain 2-byte value too: [hi, lo] + [hi, lo] => u16::from_be_bytes([*hi, *lo]).to_string(), + // Fallback: take the last two bytes as big-endian + bytes if bytes.len() >= 2 => { + let hi = bytes[bytes.len() - 2]; + let lo = bytes[bytes.len() - 1]; + u16::from_be_bytes([hi, lo]).to_string() + } + // Nothing usable + _ => String::from(""), + } +} +``` +The format of the serial number byte array is not strictly defined, so we handle a few common cases here. We had encountered this in our first implementation example of the Battery. The "serial number" of our Virtual Mock Battery is defined in that code. + +If we run the program now with `cargo run`, we should see a single line of output representing the static battery information, just following the battery-service DoInit acknowledgment, and before the repeating sequence of dynamic data updates: +``` +MockBatteryCorp MB-4200 #258, 5000 mWh, 7800 mV [LION] +``` +Now let's implement the dynamic data updates. + +Back in `integration_logic.rs`, modify the `integration_logic()` function to organize the dynamic data we receive from the battery and map it to the DisplayValues structure to send on to the `SystemObserver`. In this first case, all we will really relay is the state of charge (SOC) percentage, we'll use placeholder zero values for the rest of the fields for now: +```rust +use crate::display_models::DisplayValues; + +pub async fn integration_logic(core: &mut ControllerCore) -> Result { + let dd = core.battery.get_dynamic_data().await?; + let ia = core.sysobs.interaction_snapshot().await; + core.sysobs.update(DisplayValues { + sim_time_ms: 0.0, + soc_percent: dd.relative_soc_pct as f32, + temp_c: 0.0, + fan_level: 0, + fan_percent: 0, + fan_rpm: 0, + load_ma: 0, + charger_ma: 0, + net_batt_ma: 0, + draw_watts: 0.0, + charge_watts: 0.0, + net_watts: 0.0, + }, ia).await; + + Ok(dd) +} +``` +When the program is run now, we will get the static data, followed by a single dynamic data update report, and a number of `ControllerCore: get_dynamic_data() called` `println!` echoes after that, but no further display updates. This is because the SOC is not changing, so there is no need to send further updates to the display. + +We can remove the `println!` statement in `get_dynamic_data()` in `controller_core.rs` now, so we will only get the single report, but to have it change over time we will need to actually start attaching the simulation and integration logic. + +### Battery simulation and simulated time +You no doubt will recall that since our original `VirtualBattery` implementation, we have have an implementation named `tick()` that simulates the passage of time and the effect of load and charge on the battery state. We can continue to use this to drive our integration. We might also choose this opportunity to rewrite this simulation in a more sophisticated and potentially more accurate way, within this integration, but for now, let's just use what we have. + +You will recall that the `tick()` method is called with a parameter that indicates how many milliseconds of simulated time to advance. We can use this to drive our simulation forward in a controlled manner. The `tick()` method also takes a parameter that indicates the current in milliamps (mA) being drawn from or supplied to the battery. A positive value indicates charging, while a negative value indicates discharging. This turns out to be a little bit awkward in our present integration, but we work around it. + +Let's add the following code to the top of the `integration_logic()` function, before we call `get_dynamic_data()`: +```rust + // timing first + let speed_multiplier = { core.sysobs.interaction_snapshot().await.sim_speed_multiplier }; + let inow = Instant::now(); + let dt_s = ((inow - core.sim.last_update).as_millis() as f32 * speed_multiplier)/1000.0; + + // simulated time is real-time seconds * multiplier + let sim_time_ms = inow.as_millis() as f32 * speed_multiplier; + let now_ms = sim_time_ms as u64; // use this instead of Instant::now for time-based references + core.sim.last_update = inow; + + // inputs + let mut act_chg_ma = { core.charger.charger.state.lock().await.current() } as i32; + let soc = { core.battery.battery.state.lock().await.relative_soc_percent }; + let load_ma = { core.sysobs.interaction_snapshot().await.system_load as i32 }; + + // no charge from detached charger + if !core.chg.was_attached { + act_chg_ma = 0; + } + + let net_ma_i32 = (act_chg_ma - load_ma).clamp(-20_000, 20_000); + let net_ma = net_ma_i32 as i16; + + // mutate the model first + { + let mut bstate = core.battery.battery.state.lock().await; + bstate.set_current(net_ma); + bstate.tick(0, dt_s); + } +``` +You will need to add the following import at the top of `integration_logic.rs`: +```rust +use embassy_time::Instant; +``` +We can also change the first two fields given to DisplayValues for update to come from our new calculated values: +```rust + core.sysobs.update(DisplayValues { + sim_time_ms, + soc_percent: soc as f32, +``` +Now, when we run the program with `cargo run`, we should see the SOC percentage changing over time, along with the simulated time in the log output: +``` +MockBatteryCorp MB-4200 #258, 5000 mWh, 7800 mV [LION] +[3007](3 25) 01:15 - SOC 100.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[3259](3 25) 01:21 - SOC 99.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[9424](3 25) 03:55 - SOC 98.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[15591](3 25) 06:29 - SOC 97.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[21740](3 25) 09:03 - SOC 96.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +``` +The timing displayed in the left columns are the real-time milliseconds since the program started in [ ] brackets, followed by the speed setting and speed multiplier in ( ) parenthesis, and then the simulated time in MM:SS format. +Later, interaction control will allow us to change the speed of simulated time. + +For now, notice that the SOC percentage is decreasing over time as expected. We can also see that the other fields in the display output are still zero, as we have not yet implemented their calculation and updating. + +### Charger policy behaviors + +So now, let's attach some charger policy. Remember that we have a `charger_policy.rs` file that we defined earlier,that contains some key functions we can use now. + +In `integration_logic.rs`, just above where we call `core.sysobs.update()`, add the following code to apply the charger policy: +```rust + let mv = dd.voltage_mv; + + // --- charger policy: target + boost + slew + let watts_deficit = round_w_01(w_from_ma_mv((load_ma as f32 - act_chg_ma as f32) as i32, mv)); + let base_target = derive_target_ma(&core.cfg.policy.charger, &core.cfg.sim.device_caps, soc, load_ma); + let boost = p_boost_ma(core.cfg.policy.charger.kp_ma_per_w, core.cfg.policy.charger.p_boost_cap_ma, watts_deficit); + let target_ma = base_target.saturating_add(boost); + core.chg.requested_ma = slew_toward(core.chg.requested_ma, target_ma, dt_s, core.cfg.policy.charger.max_slew_ma_per_s); + + // Send PolicyConfiguration only when needed + if core.chg.was_attached { + let since_ms = now_ms - core.chg.last_policy_sent_at_ms; + let gap = (core.chg.requested_ma as i32 - act_chg_ma).unsigned_abs() as u16; + if since_ms >= core.cfg.policy.charger.policy_min_interval_ms + && gap >= core.cfg.policy.charger.policy_hysteresis_ma { + let cap = build_capability(&core.cfg.policy.charger, &core.cfg.sim.device_caps, soc, core.chg.requested_ma); + let _ = core.charger_service_device.execute_command(PolicyEvent::PolicyConfiguration(cap)).await; + core.chg.last_policy_sent_at_ms = now_ms; + } + } + + // --- PSU attach/detach decision + let margin = core.cfg.policy.charger.heavy_load_ma; + if load_ma > margin { + core.chg.last_heavy_load_ms = now_ms + } + + let ad = decide_attach( + &core.cfg.policy.charger, + core.chg.was_attached, + soc, + core.chg.last_psu_change_ms, + core.chg.last_heavy_load_ms, + sim_time_ms as u64 + ); + if ad.do_change { + let ev = if ad.attach { PsuState::Attached } else { PsuState::Detached }; + core.send(BusEvent::Charger(ChargerEvent::PsuStateChange(ev))).await; + core.chg.was_attached = ad.attach; + core.chg.last_psu_change_ms = now_ms; + } + +``` +We'll have to bring in these imports in order to get to our charger policy functions, and the format helpers from our display models: +```rust +use crate::policy::charger_policy::{derive_target_ma, p_boost_ma, slew_toward, build_capability, decide_attach}; +use crate::display_models::{round_w_01, w_from_ma_mv}; +use crate::events::BusEvent; +use embedded_services::power::policy::charger::PolicyEvent; +use embedded_services::power::policy::charger::{ChargerEvent,PsuState}; +``` +and this will give us new values we can use to populate our DisplayValues structure: +```rust + load_ma: load_ma as u16, + charger_ma: act_chg_ma as u16, + net_batt_ma: net_ma as i16, + draw_watts: round_w_01(w_from_ma_mv(load_ma, mv)), + charge_watts: round_w_01(w_from_ma_mv(act_chg_ma, mv)), + net_watts: round_w_01(w_from_ma_mv(net_ma as i32, mv)), +``` + +Now we should see some policy behavior in action. If we run the program with `cargo run`, we should see the SOC percentage decreasing over time, and when it reaches the attach threshold, the charger should attach, and we should see the charge current and charge watts increase, and the SOC should start to increase again. The charger will detach when the battery reaches full charge and then the cycle repeats itself. + +This behavior is roughly equivalent to what we saw in our earlier integration attempts, but now we have a more structured and modular approach to handling the display rendering and the integration logic. We'll cap this off next by adding the thermal considerations. + +### Thermal Policy + +We also created some thermal policy functions in `thermal_governor.rs` earlier. We can use these to manage the fan speed based on the battery temperature. Let's grab what we need for imports: +```rust +use crate::model::thermal_model::step_temperature; +use crate::policy::thermal_governor::process_sample; +use mock_thermal::mock_fan_controller::level_to_pwm; +``` + +and add this code above the `// --- PSU attach/detach decision` comment in `integration_logic()`: +```rust + // --- thermal model + governor + let new_temp = step_temperature( + core.sensor.sensor.get_temperature(), + load_ma, + core.therm.fan_level, + &core.cfg.sim.thermal, + dt_s + ); + let c100 = (new_temp * 100.0).round().clamp(0.0, 65535.0) as u16; + let _ = core.try_send(BusEvent::Thermal(ThermalEvent::TempSampleC100(c100))); + + let hi_on = core.cfg.policy.thermal.temp_high_on_c; + let lo_on = core.cfg.policy.thermal.temp_low_on_c; + let td = process_sample( + new_temp, + core.therm.hi_latched, core.therm.lo_latched, + hi_on, lo_on, + core.cfg.policy.thermal.fan_hyst_c, + core.therm.last_fan_change_ms, core.cfg.policy.thermal.fan_min_dwell_ms, + now_ms, + ); + core.therm.hi_latched = td.hi_latched; + core.therm.lo_latched = td.lo_latched; + if !matches!(td.threshold_event, ThresholdEvent::None) { + core.send(BusEvent::Thermal(ThermalEvent::Threshold(td.threshold_event))).await; + } + if let Some(req) = td.cooling_request { + core.send(BusEvent::Thermal(ThermalEvent::CoolingRequest(req))).await; + if td.did_step { core.therm.last_fan_change_ms = now_ms; } + } +``` +and then we can set the following `DisplayValues` fields for temperature and fan status: +```rust + temp_c: new_temp, + fan_level: core.therm.fan_level as u8, + fan_percent: level_to_pwm(core.therm.fan_level, core.cfg.sim.thermal.max_fan_level), + fan_rpm: core.fan.fan.current_rpm(), +``` + +We now have all the components integrated and reporting. But nothing too exciting is happening because we only have a consistent load on the system that we've established at the start. + +It's time to introduce some interactive UI. + diff --git a/guide_book/src/how/ec/integration/15-interaction.md b/guide_book/src/how/ec/integration/15-interaction.md new file mode 100644 index 0000000..10c6899 --- /dev/null +++ b/guide_book/src/how/ec/integration/15-interaction.md @@ -0,0 +1,139 @@ +# Interaction + +Our integration is fine, but what we really want to see here is how our component work together in a simulated system. To create a meaningful simulation of a system, we need to add some interactivity so that we can see how the system responds to user inputs and changes in state, in particular, increases and decreases to the system load the battery/charger system is supporting. + +If we return our attention to `entry.rs`, we see in `entry_task_interactive()` a commented-out spawn of an `interaction_task()`: +```rust + // spawner.spawn(interaction_task(shared.interaction_channel)).unwrap(); +``` +remove the comment characters to enable this line (or add the line if it is not present). Then add this task and helper functions: +```rust +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use embassy_time::{Duration, Timer}; + +#[embassy_executor::task] +pub async fn interaction_task(tx: &'static InteractionChannelWrapper) { + + loop { + // crossterm input poll for key events + if event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) { + if let Ok(Event::Key(k)) = event::read() { + handle_key(k, tx).await; + } + } + // loop timing set to be responsive, but allow thread relief + Timer::after(Duration::from_millis(33)).await; + } +} + +async fn handle_key(k:KeyEvent, tx:&'static InteractionChannelWrapper) { + if k.kind == KeyEventKind::Press { + match k.code { + KeyCode::Char('>') | KeyCode::Char('.') | KeyCode::Right => { + tx.send(InteractionEvent::LoadUp).await + }, + KeyCode::Char('<') | KeyCode::Char(',') | KeyCode::Left => { + tx.send(InteractionEvent::LoadDown).await + }, + KeyCode::Char('1') => { + tx.send(InteractionEvent::TimeSpeed(1)).await + }, + KeyCode::Char('2') => { + tx.send(InteractionEvent::TimeSpeed(2)).await + }, + KeyCode::Char('3') => { + tx.send(InteractionEvent::TimeSpeed(3)).await + }, + KeyCode::Char('4') => { + tx.send(InteractionEvent::TimeSpeed(4)).await + }, + KeyCode::Char('5') => { + tx.send(InteractionEvent::TimeSpeed(5)).await + } + KeyCode::Char('D') | KeyCode::Char('d') => { + tx.send(InteractionEvent::ToggleMode).await + } + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { + tx.send(InteractionEvent::Quit).await + }, + _ => {} + } + } + +} +``` +As you see, this sends `InteractionEvent` messages in response to key presses. The `InteractionEvent` enum is defined in `events.rs`. The handling for these events is also already in place in `system_observer.rs` in the `update()` method of `SystemObserver`. The `LoadUp` and `LoadDown` events adjust the system load, and the `TimeSpeed(n)` events adjust the speed of time progression in the simulation. The `ToggleMode` event switches between normal and silent modes, and the `Quit` event exits the simulation. + +What's needed to complete this cycle is a listener for these events in our main integration logic. We have already set up the `InteractionChannelWrapper` and passed it into our `ControllerCore` as `sysobs`. Now we need to add the event listening to the `ControllerCore` task. + +Add the listener task in `controller_core.rs`: +```rust +// ==== Interaction event listener task ===== +#[embassy_executor::task] +pub async fn interaction_listener_task(core_mutex: &'static Mutex) { + + let receiver = { + let core = core_mutex.lock().await; + core.interaction_channel + }; + + loop { + let event = receiver.receive().await; + match event { + InteractionEvent::LoadUp => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.increase_load().await; + }, + InteractionEvent::LoadDown => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.decrease_load().await; + }, + InteractionEvent::TimeSpeed(s) => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.set_speed_number(s).await; + }, + InteractionEvent::ToggleMode => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.toggle_mode().await; + }, + InteractionEvent::Quit => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.quit().await; + } + } + } +} +// (display event listener found in display_render.rs) +``` + +Call it from the `ControllerCore::start()` method, just after we spawn the `controller_core_task`: +```rust + println!("spawning integration_listener_task"); + if let Err(e) = spawner.spawn(interaction_listener_task(core_mutex)) { + eprintln!("spawn controller_core_task failed: {:?}", e); + } +``` + +You will notice that all of the handling for the interaction events is done through the `SystemObserver` instance that is part of `ControllerCore`. `SystemObserver` has helper methods both for sending the event messages and for handling them, mostly by delegating to other members. This keeps the interaction logic nicely encapsulated. + +Running now, we can use the key actions to raise or lower the system load, and change the speed of time progression. When we are done, we can hit `q` or `Esc` to exit the simulation instead of resorting to `ctrl-c`. + +### An improved experience +We have so far only implemented the `RenderMode::Log` version of the display renderer. This was a simple renderer to create while we were focused on getting the integration working, and it remains a valuable tool for logging the system state in a way that provides a reviewable perspective of change over time. But next, we are going to fill out the `RenderMode::InPlace` mode to provide a more interactive, app-like simulation experience. + + diff --git a/guide_book/src/how/ec/integration/16-in_place_render.md b/guide_book/src/how/ec/integration/16-in_place_render.md new file mode 100644 index 0000000..d904c89 --- /dev/null +++ b/guide_book/src/how/ec/integration/16-in_place_render.md @@ -0,0 +1,288 @@ +# In Place Rendering + +At the start of this integration example series, we discussed how this application would serve as output both for logging changes, as an interactive simulator display, and as an integration test. We have so far implemented the logging display mode, which provides a useful perspective on system state changes over time. But we also want to implement the in-place rendering mode, which will provide a more interactive experience. + +As you might guess, the key to implementing the in-place rendering mode lies in completing the implementation of the `display_render/in_place_render.rs` file. Like its already-completed counterpart, `log_render.rs`, this file implements the `DisplayRenderer` trait. The key difference is that instead of printing out log lines, it will use terminal control codes to update the display in place. + +### ANSI Escape Codes +The `InPlace` mode will use ANSI escape codes to control the terminal display. These are special sequences of characters that the terminal interprets as commands rather than text to display. For example, the sequence `\x1B[2J` clears the screen, and `\x1B[H` moves the cursor to the home position (top-left corner). By using these codes, we can create a dynamic display that updates in place. We will also make use of colors to enhance the visual experience, and use percentage bars to represent values as well as numerical data. + +### ANSI Helpers and support +We will start our `in_place_render.rs` implementation by establishing some helper functions and definitions we will use for our rendering. + +Replace the current placeholder content of `in_place_render.rs` with the following code. There are a lot of definitions here that define the specific escape code patterns to achieve ANSI terminal effects and colors. There's also some helper code for rendering pseudo-graphical elements using these techniques. Don't worry too much about these now: +```rust +use crate::display_render::display_render::{RendererBackend, time_fmt_from_ms}; +use crate::display_models::{StaticValues,DisplayValues,InteractionValues,Thresholds}; + +// ==== helpers for ANSI positional rendering ==== + +#[inline] +fn goto(row: u16, col: u16) { print!("\x1b[{};{}H", row, col); } // 1-based +#[inline] +fn clear_line() { print!("\x1b[K");} +#[inline] +fn hide_cursor() { print!("\x1b[?25l"); } +#[inline] +fn show_cursor() { print!("\x1b[?25h"); } +#[inline] +fn clear_screen() { print!("\x1b[2J\x1b[H"); } // clear + home + +// ==== ANSI helpers ==== +#[inline] fn reset() -> &'static str { "\x1b[0m" } +#[inline] fn bold() -> &'static str { "\x1b[1m" } + +#[inline] fn panel() { print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); } +#[inline] fn clear_line_panel() { panel(); clear_line(); } +#[inline] fn line_start(row: u16, col: u16) { goto(row, col); clear_line_panel(); } + +#[inline] fn fg(code: u8) -> String { format!("\x1b[{}m", code) } // 30–37,90–97 +#[inline] fn bg(code: u8) -> String { format!("\x1b[{}m", code) } // 40–47,100–107 + +// 8/16-color palette picks that read well on most terminals: +const FG_DEFAULT: u8 = 97; // bright white +const BG_PANEL: u8 = 40; // black bg to isolate our content panel +const FG_GOOD: u8 = 92; // bright green +const BG_GOOD: u8 = 42; // green bg +const FG_WARN: u8 = 93; // bright yellow +const BG_WARN: u8 = 43; // yellow bg +const FG_DANGER: u8 = 91; // bright red +const BG_DANGER: u8 = 41; // red bg +const BG_EMPTY: u8 = 100; // bright black/gray for bar remainder + + +#[derive(Clone, Copy)] +struct ZoneColors { fg: u8, bg: u8 } + +/// Pick a color zone based on value relative to warn/danger. +/// If `good_is_high == true`, larger is greener (SOC, charge). +/// If `good_is_high == false`, larger is worse (Temp, Draw). +fn pick_zone(value: f32, warn: f32, danger: f32, good_is_high: bool) -> ZoneColors { + let (good, warn_c, danger_c) = ( + ZoneColors { fg: FG_GOOD, bg: BG_GOOD }, + ZoneColors { fg: FG_WARN, bg: BG_WARN }, + ZoneColors { fg: FG_DANGER, bg: BG_DANGER }, + ); + + if good_is_high { + if value <= danger { danger_c } + else if value <= warn { warn_c } + else { good } + } else { + // higher is worse: reverse comparisons + if value >= danger { danger_c } + else if value >= warn { warn_c } + else { good } + } +} + +/// Render a solid block bar with colorized background for the fill, +/// neutral gray background for the remainder, and visible brackets. +fn block_bar(frac: f32, width: usize, fill_zone: ZoneColors) -> String { + let frac = frac.clamp(0.0, 1.0); + let fill = core::cmp::min((frac * width as f32).round() as usize, width); + + let mut s = String::with_capacity(width + 10); + + // Bracket left in panel colors + s.push_str(&fg(FG_DEFAULT)); s.push_str(&bg(BG_PANEL)); s.push('['); + + // Filled segment + if fill > 0 { + s.push_str(&fg(fill_zone.fg)); + s.push_str(&bg(fill_zone.bg)); + for _ in 0..fill { s.push('█'); } + } + + // Empty remainder (neutral background for readability) + if fill < width { + s.push_str(&fg(FG_DEFAULT)); + s.push_str(&bg(BG_EMPTY)); + for _ in fill..width { s.push(' '); } + } + + // Bracket right back to panel bg + s.push_str(&fg(FG_DEFAULT)); s.push_str(&bg(BG_PANEL)); s.push(']'); + s.push_str(reset()); + s +} + + +// ====== +const ROW_TITLE: u16 = 1; +const ROW_HELP: u16 = 4; +const ROW_INFO1: u16 = 6; // manufacturer / name / serial / chem +const ROW_INFO2: u16 = 7; // voltage, capacity +const ROW_LINE: u16 = 8; // separator +const ROW_SOC: u16 = 9; // dynamic begins here +const ROW_DRAW: u16 = 10; +const ROW_CHG: u16 = 11; +const ROW_NET: u16 = 12; +const ROW_TEMP: u16 = 13; +const ROW_LINE2: u16 = 14; +const ROW_TIME: u16 = 15; +const ROW_LOG: u16 = 18; + +const COL_LEFT: u16 = 2; +const COL_SPEED: u16 = COL_LEFT + 30; +const COL_TIME: u16 = COL_LEFT + 58; +const BAR_W: usize = 36; +``` +This code will set us up with the basic building blocks we need to create our in-place rendering. We have defined a set of ANSI escape code helpers for cursor movement, screen clearing, and color setting. We have also defined some constants for colors that work well together on most terminals, as well as functions to pick colors based on value zones (good, warning, danger) and to render a block bar with colorized segments. +We have also defined constants for the row and column positions of various elements in our display, which will help us position our output correctly. + +Now we can implement our `InPlaceBackend` struct and its `RendererBackend` trait methods, including the all-important `render_frame()` method that updates dynamic changes to the display, and the `render_static()` method that sets up the static parts of the display at the beginning, and gives us a key command 'help' reference. + +```rust +pub struct InPlaceBackend { + th: Thresholds +} +impl InPlaceBackend { + pub fn new() -> Self { + Self { + th:Thresholds::new() + } + } +} +impl RendererBackend for InPlaceBackend { + fn on_enter(&mut self, last: Option<&DisplayValues>) { + // any setup or restore necessary + let _ = last; + clear_screen(); + hide_cursor(); + // Set a consistent panel background + default bright text + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + } + fn on_exit(&mut self) { + // teardown + print!("{}", reset()); + clear_screen(); + show_cursor(); + } + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues) { + // keep panel colors active + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + + let max_w = self.th.max_load.max(1.0); + + // Split for display only (both non-negative) + let draw_w = dv.draw_watts.max(0.0); + let charge_w = dv.charge_watts.max(0.0); + let net_w = dv.net_watts; + + let draw_frac = (draw_w / max_w).clamp(0.0, 1.0); + let charge_frac = (charge_w / max_w).clamp(0.0, 1.0); + let soc_frac = (dv.soc_percent / 100.0).clamp(0.0, 1.0); + + let (speed_number, speed_multiplier) = ia.get_speed_number_and_multiplier(); + + // === SOC (good is high: warn/danger are lower thresholds) === + let soc_zone = pick_zone( + dv.soc_percent, + self.th.warning_charge, + self.th.danger_charge, + true + ); + + line_start(ROW_SOC, COL_LEFT); + print!("SOC {:5.1}% ", dv.soc_percent); + println!("{}", block_bar(soc_frac, BAR_W, soc_zone)); + + // === Draw (higher is worse) === + let draw_zone = pick_zone( + draw_w, + self.th.max_load * 0.5, // tweakable: 50% = warn + self.th.max_load * 0.8, // tweakable: 80% = danger + false + ); + line_start(ROW_DRAW, COL_LEFT); + print!("Draw {:7.3} W {:5.1}% ", draw_w, draw_frac * 100.0); + println!("{}", block_bar(draw_frac, BAR_W, draw_zone)); + + // === Charge === + let chg_zone = pick_zone( + charge_w, + draw_w, // warn if only charging == draw + draw_w *0.8, // danger of running out if charge < draw + true + ); + line_start(ROW_CHG, COL_LEFT); + print!("Charge{:7.3} W {:5.1}% ", charge_w, charge_frac * 100.0); + println!("{}", block_bar(charge_frac, BAR_W, chg_zone)); + + // === Net (color arrow by direction) === + let dir = if net_w >= 0.0 { + format!("{}→ Charge{}", fg(FG_GOOD), reset()) + } else { + format!("{}← Draw{}", fg(FG_DANGER), reset()) + }; + // keep panel colors around printed arrow + let _ = print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + line_start(ROW_NET, COL_LEFT); + println!("Net {:+7.3} W {}", net_w, dir); + + // === Temp/Fan (higher is worse) === + let temp_zone = pick_zone( + dv.temp_c, + self.th.warning_temp, + self.th.danger_temp, + false + ); + line_start(ROW_TEMP, COL_LEFT); + print!("Temp {:5.1} °C ", dv.temp_c); + print!("{}{}Fan: ", fg(temp_zone.fg), bg(BG_PANEL)); + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + println!(" L{} ({}%) -- {} rpm", dv.fan_level, dv.fan_percent, dv.fan_rpm); + + // line + footer + line_start(ROW_LINE2, COL_LEFT); + println!("==============================================================="); + goto(ROW_TIME, COL_SPEED); clear_line(); + println!("Speed: {} ({} X)", speed_number, speed_multiplier); + goto(ROW_TIME, COL_TIME); clear_line(); + println!("{}", time_fmt_from_ms(dv.sim_time_ms)); + + // log area + line_start(ROW_LOG, COL_LEFT); + } + + fn render_static(&mut self, sv: &StaticValues) { + clear_screen(); + // re-assert panel colors + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + + goto(ROW_TITLE, COL_LEFT); + println!("==============================================================="); + goto(ROW_TITLE+1, COL_LEFT); + println!("{}ODP Component Integration Simulation{}", bold(), reset()); + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + goto(ROW_TITLE+2, COL_LEFT); + println!("==============================================================="); + goto(ROW_HELP, COL_LEFT); clear_line(); + println!("Keyboard commands: ESC to exit, < > to change load, 1-5 to set sim speed"); + goto(ROW_HELP+1, COL_LEFT); clear_line(); + println!("---------------------------------------------------------------"); + goto(ROW_INFO1, COL_LEFT); clear_line(); + println!("{} {} #{}", sv.battery_mfr, sv.battery_name, sv.battery_serial); + goto(ROW_INFO2, COL_LEFT); clear_line(); + println!("{} mWh, {} mV [{}]", sv.battery_dsgn_cap_mwh, sv.battery_dsgn_voltage_mv, sv.battery_chem); + goto(ROW_LINE, COL_LEFT); + println!("---------------------------------------------------------------"); + } +} +``` +Finally, our `entry.rs` `render_task()` function is currently set to the default of `RenderMode::Log`. We can change that to `RenderMode::InPlace` to use our new renderer: +```rust + let mut r = DisplayRenderer::new(RenderMode::InPlace); +``` +We can also toggle between log and in-place modes by pressing the 'D' key while the simulation is running, but this starts us of in an application-like mode right away. + +When you run your display should now look like this: +![in_place_render](./media/integration-sim.png) + +You can use the <> or left/right arrow keys to raise or lower the system load, and the number keys 1-5 to set the speed of time progression. The display updates in place, providing a more interactive experience. To toggle between this display and the logging mode, press `d`. When you are done, you can hit `q` or `Esc` to exit the simulation instead of resorting to `ctrl-c`. + +You now have a interactive simulation application to test the behavior of your integrated components over a range of conditions. This type of app is a powerful tool for understanding how your components work together, and for identifying any issues that may arise in real-world usage. + +Next up, we'll use what we have learned from these interactions to devise some automated tests to validate the integration in an unattended way. + diff --git a/guide_book/src/how/ec/integration/17-integration_test.md b/guide_book/src/how/ec/integration/17-integration_test.md new file mode 100644 index 0000000..c93a0f3 --- /dev/null +++ b/guide_book/src/how/ec/integration/17-integration_test.md @@ -0,0 +1,181 @@ +# Integration Test +At long last, we are at the integration test portion of this exercise -- along the way, we have created an integration that we can empirically run and evaluate, but a true integration test is automated, repeatable, and ideally part of a continuous integration (CI) process. We will create a test that runs the simulation for a short period of time, exercising the various components and their interactions, and then we will evaluate the results to ensure that they are as expected. + +A true integration test is invaluable in an environment where components are being actively developed, as it provides a way to ensure that changes in one component do not inadvertently break the overall system. It also provides a way to validate that the system as a whole is functioning as intended, and that the various components are interacting correctly. + +When things do begin to differ, one can use the interactive modes of an application such as this one to explore and understand the differences, and then make adjustments as needed. + +### Back to the DisplayRenderer +Our latest revision in the exercise was to create an in-place renderer that provides a more interactive experience. We can use the same mechanism to "render" to a testing construct that collects the results of simulated situations, evaluates them, and reports the results. + +This is similar to the Test Observer pattern used in previous examples, although adapted here for this new context. + +## Feature selection +We don't want our test mode "display" to be one of the toggle options of our simulation app. Rather, we want this to be selected at the start when we run the app in "integration-test" mode. So let's define some feature flags that will define our starting modes: + +So, before we even start defining our integration test support, let's posit that this will be a separately selectable compile and runtime mode that we want to designate with a `--features` flag. + +Our `Cargo.toml` already defines a `[features]` section that was mostly inherited from previous integration examples, and establishes the thread mode to use in different contexts. +We will keep that part of things intact so as not to interfere with the behavior of our dependent crates, +but we will extend it to introduce modes for `log-mode`, `in-place-mode` and `integration-test` mode, with `in-place-mode` being the default if no feature selection is made explicitly. + +In `Cargo.toml` +```toml +[features] +default = ["in-place-mode"] +integration-test = ["std", "thread-mode"] +log-mode = ["std", "thread-mode"] +in-place-mode = ["std", "thread-mode"] +std = [] +thread-mode = [ + "mock_battery/thread-mode", + "mock_charger/thread-mode", + "mock_thermal/thread-mode" +] +noop-mode = [ + "mock_battery/noop-mode", + "mock_charger/noop-mode", + "mock_thermal/noop-mode" +] +``` +Then, in `main.rs` we can use this to choose which of our starting tasks we wish to launch: +```rust +#[embassy_executor::main] +async fn main(spawner: Spawner) { + + #[cfg(feature = "integration-test")] + spawner.spawn(entry::entry_task_integration_test(spawner)).unwrap(); + + #[cfg(not(feature = "integration-test"))] + spawner.spawn(entry::entry_task_interactive(spawner)).unwrap(); +} +``` +This will set apart the integration test into a separate launch we will establish in `entry.rs` as well as +further separating the selection of `RenderMode::Log` vs. `RenderMode::InPlace` as the default to start with when not in test mode. + +In `entry.rs`, create the new entry task, and modify the render_task so that the `RenderMode` is passed in: +```rust +#[cfg(feature = "integration-test")] +#[embassy_executor::task] +pub async fn entry_task_integration_test(spawner: Spawner) { + println!("🚀 Integration test mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + spawner.spawn(render_task(shared.display_channel, RenderMode::IntegrationTest)).unwrap(); +} + +#[embassy_executor::task] +pub async fn render_task(rx: &'static DisplayChannelWrapper, mode:RenderMode) { + let mut r = DisplayRenderer::new(mode); + r.run(rx).await; +} +``` +Then, let's modify `entry_task_interactive` to respect the feature options for starting `RenderMode` as well: +```rust +#[embassy_executor::task] +pub async fn entry_task_interactive(spawner: Spawner) { + println!("🚀 Interactive mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + spawner.spawn(interaction_task(shared.interaction_channel)).unwrap(); + + #[cfg(feature = "log-mode")] + let mode = RenderMode::Log; + #[cfg(not(feature = "log-mode"))] + #[cfg(feature = "in-place-mode")] + let mode = RenderMode::InPlace; + + spawner.spawn(render_task(shared.display_channel, mode)).unwrap(); +} +``` + +### RenderMode::IntegrationTest + +We need to add the integration test mode to our `RenderMode` enum, and we need to create a placeholder for the rendering backend it will represent. + +In `events.rs`, modify the `RenderMode` enum to now be: +```rust +pub enum RenderMode { + InPlace, // ANSI Terminal application + Log, // line-based console output + IntegrationTest // Collector/Reporter for testing +} +``` + +Then create a new file in the `display_render` folder named `integration_test_render.rs` and give it this placeholder content for now: +```rust + +use crate::display_render::display_render::{RendererBackend}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; + +pub struct IntegrationTestBackend; +impl IntegrationTestBackend { pub fn new() -> Self { Self } } +impl RendererBackend for IntegrationTestBackend { + fn render_frame(&mut self, _dv: &DisplayValues, _ia: &InteractionValues) { + } + fn render_static(&mut self, _sv: &StaticValues) { + } +} +``` +this won't actually do anything more yet other than satisfy our traits for a valid backend renderer. + +we need to add this also to `display_render/mod.rs`: +``` +// display_render +pub mod display_render; +pub mod log_render; +pub mod in_place_render; +pub mod integration_test_render; +``` + +In `display_render.rs`, we can import this: +```rust +use crate::display_render::integration_test_render::IntegrationTestBackend; +``` + +and add it to the `match` statement of `make_backend()`: +```rust + fn make_backend(mode: RenderMode) -> Box { + match mode { + RenderMode::InPlace => Box::new(InPlaceBackend::new()), + RenderMode::Log => Box::new(LogBackend::new()), + RenderMode::IntegrationTest => Box::new(IntegrationTestBackend::new()) + } + } +``` + +now, we should be able to run in different modes from feature flags: + +``` +cargo run --features in-place-mode +``` +or simply +``` +cargo run +``` +should give us our ANSI "In Place" app-style rendering. +``` +cargo run --features log-mode +``` +should give us our log mode output from the start. +``` +cargo run --features integration-test +``` +should not emit anything past the initial `println!` statements up through `DoInit`, since we have a non-functional rendering implementation in place here. + +Next, let's explore how we want to conduct our integration tests. + + + diff --git a/guide_book/src/how/ec/integration/18-integration_test_structure.md b/guide_book/src/how/ec/integration/18-integration_test_structure.md new file mode 100644 index 0000000..4c2b517 --- /dev/null +++ b/guide_book/src/how/ec/integration/18-integration_test_structure.md @@ -0,0 +1,549 @@ +# Integration Test Structure + +Let's imagine a framework where we can set our expectations for our integration behavior over time or between states, then set the integration into motion where these expectations are tested, and get a report on what has passed and failed. We can repeat different sets of such tests until we are satisfied we have tested everything we want to. + +Such a framework would include a + +- a `TestReporter` that + - tracks the start and end of a testing period, checking to see if the period is complete + - records the evaluations that are to occur for this time period, and marks them as pass of fail + - reports the outcomes of these tests + +- a `Test entry` function that puts all of this into motion and defines the tests for each section and the scope of the tests. + +## Components of the TestReporter + +- TestResult enum +- Test structure, name, result, message +- evaluation trait +- assert helpers +- collection of Tests + +Let's create `test_reporter.rs` and give it this content: +```rust +// test_reporter.rs + +use std::fmt::{Display, Formatter}; +use std::time::Instant; + +/// Result of an evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestResult { + Pending, + Pass, + Fail, +} +impl Default for TestResult { + fn default() -> Self { + Self::Pending + } +} +impl Display for TestResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + TestResult::Pending => write!(f, "PENDING"), + TestResult::Pass => write!(f, "PASS"), + TestResult::Fail => write!(f, "FAIL"), + } + } +} + +/// Per-test outcome produced by running an Evaluator. +#[derive(Debug, Clone)] +pub struct TestEval { + pub name: &'static str, + pub result: TestResult, + pub message: Option, + // pub elapsed: Option, +} +impl TestEval { + pub fn new(name: &'static str) -> Self { + Self { name, result: TestResult::Pending, message: None} + } +} + +/// Pass to mark pass/fail and attach messages. +#[derive(Debug, Default)] +pub struct TestObserver { + result: TestResult, + message: Option, +} +#[allow(unused)] +impl TestObserver { + // assume a test with no failures is considered passing. + pub fn new() -> Self { Self { result: TestResult::Pass, message: None } } + pub fn pass(&mut self) { self.result = TestResult::Pass; } + pub fn fail(&mut self, reason: impl Into) { + self.result = TestResult::Fail; + self.message = Some(reason.into()); + } + pub fn result(&self) -> TestResult { self.result } + pub fn message(&self) -> Option<&str> { self.message.as_deref() } +} + +/// Trait each test implements. `run` should set PASS/FAIL on the observer. +pub trait Evaluator: Send { + fn name(&self) -> &'static str; + fn run(&mut self, obs: &mut TestObserver); +} + +/// Helper: wrap a closure as an Evaluator. +pub struct FnEval { + name: &'static str, + f: Box, +} +impl FnEval { + pub fn new(name: &'static str, f: impl FnMut(&mut TestObserver) + Send + 'static) -> Self { + Self { name, f: Box::new(f) } + } +} +impl Evaluator for FnEval { + fn name(&self) -> &'static str { self.name } + fn run(&mut self, obs: &mut TestObserver) { (self.f)(obs) } +} + +/// A collection of tests plus section timing/reporting. +pub struct TestReporter { + tests: Vec>, + results: Vec, + section_start: Option, + section_end: Option, +} +#[allow(unused)] +impl TestReporter { + pub fn new() -> Self { + Self { tests: Vec::new(), results: Vec::new(), section_start: None, section_end: None } + } + + /// Register any Evaluator. + pub fn add_test(&mut self, eval: E) { + self.tests.push(Box::new(eval)); + } + + /// Convenience: register inline closures. + pub fn add_inline(&mut self, name: &'static str, f: impl FnMut(&mut TestObserver) + Send + 'static) { + self.add_test(FnEval::new(name, f)); + } + + /// Begin a new section: clears previous results but keeps registered tests + /// (so you can re-run same suite against evolving state). Call `clear_tests()` + /// if you want to rebuild the suite per section. + pub fn start_test_section(&mut self) { + self.results.clear(); + self.section_start = Some(Instant::now()); + self.section_end = None; + } + + /// Optionally rebuild the suite. + pub fn clear_tests(&mut self) { + self.tests.clear(); + self.results.clear(); + } + + /// Execute tests and capture results. + pub fn evaluate_tests(&mut self) { + self.results.clear(); + + for t in self.tests.iter_mut() { + let mut obs = TestObserver::new(); + let t_start = Instant::now(); + t.run(&mut obs); + // let elapsed = t_start.elapsed(); + + let result = obs.result; + + let mut ev = TestEval::new(t.name()); + ev.result = result; + ev.message = obs.message().map(|s| s.to_string()); + // ev.elapsed = Some(elapsed); + self.results.push(ev); + } + } + + /// End the section and print/report. + /// returns 0 on success or -1 on failures, which can be used as a reported error code for exit + pub fn end_test_section(&mut self) -> i32 { + self.section_end = Some(Instant::now()); + self.print_report() + } + + /// Aggregate and emit a summary report. Replace with your display backend as needed. + /// returns 0 on success or -1 on failures, which can be used as a reported error code for exit + pub fn print_report(&self) -> i32 { + let total = self.results.len(); + let passed = self.results.iter().filter(|r| r.result == TestResult::Pass).count(); + let failed = self.results.iter().filter(|r| r.result == TestResult::Fail).count(); + + let sec_elapsed = self.section_start + .zip(self.section_end) + .map(|(s, e)| e.duration_since(s)); + + println!("=================================================="); + println!(" Test Section Report"); + if let Some(d) = sec_elapsed { + println!(" Duration: {:?}\n", d); + } + + for r in &self.results { + // let time = r.elapsed.map(|d| format!("{:?}", d)).unwrap_or_else(|| "-".into()); + match (&r.result, &r.message) { + (TestResult::Pass, _) => { + println!("[PASS] {:<40}", r.name); + } + (TestResult::Fail, Some(msg)) => { + println!("[FAIL] {:<40} — {}", r.name, msg); + } + (TestResult::Fail, None) => { + println!("[FAIL] {:<40}", r.name); + } + (TestResult::Pending, _) => { + println!("[PEND] {:<40}", r.name); + } + } + } + println!("\n Summary: total={}, passed={}, failed={}", total, passed, failed); + println!("=================================================="); + + // return error code 0 == success, -1 == failure + if total == passed { 0 } else { -1 } + } + + /// Retrieve results programmatically (e.g., to feed a UI). + pub fn results(&self) -> &[TestEval] { &self.results } +} + +// Simple assertion macros + +/// Test a boolean expression +/// Usage: expect!(obs, is_true, _optional_message); +#[macro_export] +macro_rules! expect { + ($obs:expr, $cond:expr, $($msg:tt)*) => {{ + if !($cond) { + $obs.fail(format!($($msg)*)); + return; + } + }}; +} + +/// Compare two values for equality +/// Usage: expect_eq!(obs, actual, expected, _optional_message); +#[macro_export] +macro_rules! expect_eq { + ($obs:expr, $left:expr, $right:expr $(, $($msg:tt)*)? ) => {{ + if $left != $right { + let msg = format!( + concat!("expected == actual, but got:\n expected: {:?}\n actual: {:?}", $(concat!("\n ", $($msg)*))?), + &$right, &$left + ); + $obs.fail(msg); + return; + } + }}; +} + +/// Compare two numbers after rounding to `places` decimal places. +/// Usage: expect_to_decimal!(obs, actual, expected, places); +#[macro_export] +macro_rules! expect_to_decimal { + ($obs:expr, $actual:expr, $expected:expr, $places:expr $(,)?) => {{ + // Work in f64 for better rounding behavior, then compare the rounded integers. + let a_f64: f64 = ($actual) as f64; + let e_f64: f64 = ($expected) as f64; + let places_u: usize = ($places) as usize; + let scale: f64 = 10f64.powi(places_u as i32); + + let a_round_i = (a_f64 * scale).round() as i64; + let e_round_i = (e_f64 * scale).round() as i64; + + if a_round_i == e_round_i { + $obs.pass(); + } else { + // Nice message with the same precision the comparison used + let a_round = a_round_i as f64 / scale; + let e_round = e_round_i as f64 / scale; + + let msg = format!( + "expected ~= {e:.prec$} but got {a:.prec$} (rounded to {places} dp; {e_round:.prec$} vs {a_round:.prec$})", + e = e_f64, + a = a_f64, + e_round = e_round, + a_round = a_round, + prec = places_u, + places = places_u + ); + $obs.fail(&msg); + } + }}; +} + +/// Syntactic sugar to add inline tests: +/// add_test!(reporter, "Name", |obs| { /* ... */ }); +#[macro_export] +macro_rules! add_test { + ($reporter:expr, $name:expr, |$obs:ident| $body:block) => {{ + $reporter.add_inline($name, move |$obs: &mut TestObserver| $body); + }}; +} +``` +This establishes the feature framework we discussed above. It is able to collect and report on test evaluations for one or many test sections, and provides some helpful macros such as `add_test!` to register a closure as the evaluation function as well as some assertion macros designed to use with the `TestObserver`. + +Add this as a module also to `main.rs`: +```rust +mod test_reporter; +``` + +## Wiring it into the IntegrationTest DisplayRenderer +Central to our plan is the idea that we can make a `DisplayRenderer` variant that will feed us the results of the simulation as it runs. We can then evaluate these values in context and assign Pass/Fail results to the `TestReporter` and print out the final tally. + +To do this, we need to "tap" the `render_static` and `render_frame` traits of our `IntegrationTestBackend` and feed this data into where we are running the test code. + +### Adding the TestTap + +Let's replace our placeholder `integration_test_render.rs` file with this new version: +```rust + +use crate::display_render::display_render::{RendererBackend}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; +use ec_common::mutex::{Mutex, RawMutex}; + +pub trait TestTap: Send + 'static { + fn on_static(&mut self, sv: &StaticValues); + fn on_frame(&mut self, dv: &DisplayValues); +} + +struct NullTap; +#[allow(unused)] +impl TestTap for NullTap { + fn on_static(&mut self, _sv: &StaticValues) {} + fn on_frame(&mut self, _dv: &DisplayValues) {} +} + +pub struct IntegrationTestBackend { + tap: Mutex> +} +impl IntegrationTestBackend { + pub fn new() -> Self { + Self { + tap: Mutex::new(Box::new(NullTap)) + } + } +} +impl RendererBackend for IntegrationTestBackend { + fn render_frame(&mut self, dv: &DisplayValues, _ia: &InteractionValues) { + let mut t = self.tap.try_lock().expect("tap locked in another task?"); + t.on_frame(dv); + } + fn render_static(&mut self, sv: &StaticValues) { + let mut t = self.tap.try_lock().expect("tap locked in another task?"); + t.on_static(sv); + } + #[cfg(feature = "integration-test")] + fn set_test_tap(&mut self, tap: Box) { + let mut guard = self.tap.try_lock().expect("tap locked in another task?"); + *guard = tap; + }} +``` + +You will see that we have defined a `TestTap` trait that provides us with the callback methods we are looking for to feed our test running code. We've given a concrete implementation `NullTap` to use as a no-op stub to hold fort until we replace it with `set_test_tap()` later. + +We will need to make some changes to our `display_render.rs` file to accommodate this. Open up that file and add the following: + +```rust +use crate::display_render::integration_test_render::IntegrationTestBackend; +#[cfg(feature = "integration-test")] +use crate::display_render::integration_test_render::TestTap; + +``` + +Change the trait definition for `RendererBackend` to now be: +```rust +// Define a trait for the interface for a rendering backend +pub trait RendererBackend : Send + Sync { + fn on_enter(&mut self, _last: Option<&DisplayValues>) {} + fn on_exit(&mut self) {} + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues); + fn render_static(&mut self, sv: &StaticValues); + #[cfg(feature = "integration-test")] + fn set_test_tap(&mut self, _tap: Box) {} +} +``` + +This gives us the ability to set the test tap, and it defaults to nothing unless we implement it, as we have done already in `integration_test_render.rs`. + +Now add this function to the `impl DisplayRender` block: +```rust + #[cfg(feature = "integration-test")] + pub fn set_test_tap(&mut self, tap: T) -> Result<(), &'static str> + where + T: TestTap + Send + 'static, + { + if self.mode != RenderMode::IntegrationTest { + return Err("Renderer is not in Integration Test mode"); + } + self.backend.set_test_tap(Box::new(tap)); + Ok(()) + } +``` + +### Using these changes in test code + +Now we can start to build in the test code itself. + +We'll create a new file for this: `integration_test.rs` and give it this content: +```rust +#[cfg(feature = "integration-test")] +pub mod test_module { + + use crate::test_reporter::test_reporter::TestObserver; + use crate::test_reporter::test_reporter::TestReporter; + use crate::{add_test,expect, expect_eq}; + use crate::display_models::{DisplayValues, StaticValues}; + use crate::display_render::integration_test_render::TestTap; + use crate::entry::DisplayChannelWrapper; + use crate::display_render::display_render::DisplayRenderer; + use crate::events::RenderMode; + + #[embassy_executor::task] + pub async fn integration_test(rx: &'static DisplayChannelWrapper) { + + let mut reporter = TestReporter::new(); + + reporter.start_test_section(); + + + struct ITest { + reporter: TestReporter, + first_time: Option, + test_time_ms: u64, + saw_static: bool, + frame_count: i16 + } + impl ITest { + pub fn new() -> Self { + Self { + reporter: TestReporter::new(), + first_time: None, + test_time_ms: 0, + saw_static: false, + frame_count: 0 + } + } + } + impl TestTap for ITest { + fn on_static(&mut self, sv: &StaticValues) { + add_test!(self.reporter, "Static Values received", |obs| { + obs.pass(); + }); + self.saw_static = true; + println!("🔬 Integration testing starting..."); + } + fn on_frame(&mut self, dv: &DisplayValues) { + let load_ma= dv.load_ma; + let first = self.first_time.get_or_insert(dv.sim_time_ms as u64); + self.test_time_ms = (dv.sim_time_ms as u64).saturating_sub(*first); + + if self.frame_count == 0 { + // ⬇️ Take snapshots so the closure doesn't capture `self` + let saw_static_snapshot = self.saw_static; + let load_at_start = load_ma; + let expected = 1200; + + add_test!(self.reporter, "First Test Data Frame received", |obs| { + expect!(obs, saw_static_snapshot, "Static Data should have come first"); + expect_eq!(obs, load_at_start, expected, "Load value at start"); + obs.pass(); + }); + } + + self.frame_count += 1; + + if self.test_time_ms > 5_000 { + // `self` is fine to use here; the borrow from add_test! ended at the call. + self.reporter.evaluate_tests(); + self.reporter.print_report(); + std::process::exit(0); + } + } + } + let mut r = DisplayRenderer::new(RenderMode::IntegrationTest); + r.set_test_tap(ITest::new()).unwrap(); + r.run(rx).await; + + } +} +``` +Note that we've wrapped this entire file content as a module and gated it behind `#[cfg(feature = "integration-test")]` so that it is only valid in integration-test mode. + +add this module to `main.rs` +```rust +mod integration_test; +``` + +and in `entry.rs`, add the import for this task: +```rust +#[cfg(feature = "integration-test")] +use crate::integration_test::test_module::integration_test; +``` + +also in `entry.rs`, replace the spawn of `render_task` with the spawn to `integration_test`, passing the display channel that will continue to be used for our tapped Display messaging which will now route to our test code. +```rust +#[cfg(feature = "integration-test")] +#[embassy_executor::task] +pub async fn entry_task_integration_test(spawner: Spawner) { + println!("🚀 Integration test mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + spawner.spawn(integration_test(shared.display_channel)).unwrap(); +} +``` + +A `cargo run --features integration-test` should produce the following output: +``` + Running `C:\Users\StevenOhmert\odp\ec_examples\target\debug\integration_project.exe` +🚀 Integration test mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start() +spawning controller_core_task +spawning start_charger_task +spawning charger_policy_event_task +spawning integration_listener_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +battery-service DoInit -> Ok(Ack) +🔬 Integration testing starting... +================================================== + Test Section Report +[PASS] Static Values received (1µs) +[PASS] First Test Data Frame received (400ns) + + Summary: total=2, passed=2, failed=0 +================================================== +``` + +That's a good proof-of-concept start. Let's create some meaningful tests now. + + + + + + + diff --git a/guide_book/src/how/ec/integration/19-meaningful_tests.md b/guide_book/src/how/ec/integration/19-meaningful_tests.md new file mode 100644 index 0000000..215d67c --- /dev/null +++ b/guide_book/src/how/ec/integration/19-meaningful_tests.md @@ -0,0 +1,253 @@ +# Adding Meaningful Tests + +We have the ability to run the app as an interactive simulation, including the logging mode that will output a running record of the changes over time as we change the load. + +So, it makes sense to derive some sense of expected behavior from these results and model tests that correspond to this. + +## What are we really testing? + +Of course, this is a simulated integration of virtual components -- running simulation algorithms as stand-ins for actual physical behaviors -- so when we run our tests, we are also testing the realism of these sims. Although reasonable effort has been made to account for the physics of temperature change, battery life, and so on, it should not be expected that these are precisely accurate. In a real integration, you don't get to change the effects of physics -- so we'll test against the physical reality as it is presented to us, realistic or otherwise. + +Running with the default configurations as we have built them in this example, we can observe the battery starts off at 100% SOC and we have a starting default system load/draw of 9.4W. The battery thus discharges to a point where the charger activates below 90%, then charges back up to 100%, detaches the charger, and the cycle repeats. + +If we increase the load during any of this, the battery discharges faster, and the temperature rises more quickly and at the configured point of 28 degrees celsius, the fan turns on to facilitate cooling. The ability of the fan to counter the load depends upon the continued draw level, and whether or not the charger is running. If cooling is sufficient, the fan slows, and under lower load, will turn off. + +As we've written it, the test context does not have the ability to change the simulated time multiplier the way the interactive context allows, so all simulation time for the test runs at the pre-configured level 3 (25X). + +### Running faster +Since this is a test, we don't need to dally. Let's make a change so that the default level for the integration-test mode is level 5 (100X). In `controller_core.rs` add the following lines below the temperature threshold settings within the set-aside mutex lock block: +```rust + + #[cfg(feature = "integration-test")] + core.sysobs.set_speed_number(5).await; +``` + +## Checking static, then stepwise events + +Our initial tests already establish that static data is received, and verifies the one-time-at-the-start behavior is respected, but we don't check any values. This is largely superfluous, of course, but we should verify anyway. + +Following this first event, we need a good way to know where we are at in the flow of subsequent events so that we can properly evaluate and direct the context at the time. + +Let's update our current `integration_test.rs` code with a somewhat revised version: +```rust +#[cfg(feature = "integration-test")] +pub mod integration_test { + use crate::test_reporter::test_reporter::TestObserver; + use crate::test_reporter::test_reporter::TestReporter; + use crate::{add_test,expect, expect_eq}; + use crate::display_models::{DisplayValues, StaticValues}; + use crate::display_render::integration_test_render::TestTap; + use crate::entry::DisplayChannelWrapper; + use crate::display_render::display_render::DisplayRenderer; + use crate::events::RenderMode; + + #[allow(unused)] + #[derive(Debug)] + enum TestStep { + None, + CheckStartingValues, + CheckChargerAttach, + RaiseLoadAndCheckTemp, + RaiseLoadAndCheckFan, + LowerLoadAndCheckCooling, + EndAndReport + } + + #[embassy_executor::task] + pub async fn integration_test(rx: &'static DisplayChannelWrapper) { + + struct ITest { + reporter: TestReporter, + first_time: Option, + test_time_ms: u64, + saw_static: bool, + frame_count: i16, + step: TestStep, + } + impl ITest { + pub fn new() -> Self { + let mut reporter = TestReporter::new(); + reporter.start_test_section(); // start out with a new section + Self { + reporter, + first_time: None, + test_time_ms: 0, + saw_static: false, + frame_count: 0, + step: TestStep::None + } + } + + // -- Individual step tests --- + + fn check_starting_values(&mut self, draw_watts:f32) -> TestStep { + let reporter = &mut self.reporter; + add_test!(reporter, "Check Starting Values", |obs| { + expect_eq!(obs, draw_watts, 9.4); + obs.pass(); + }); + TestStep::EndAndReport + } + + // --- final step to report and exit -- + fn end_and_report(&mut self) { + let reporter = &mut self.reporter; + reporter.evaluate_tests(); + reporter.end_test_section(); + std::process::exit(0); + } + } + impl TestTap for ITest { + fn on_static(&mut self, sv: &StaticValues) { + let _ = sv; + add_test!(self.reporter, "Static Values received", |obs| { + obs.pass(); + }); + self.saw_static = true; + println!("🔬 Integration testing starting..."); + } + fn on_frame(&mut self, dv: &DisplayValues) { + + let reporter = &mut self.reporter; + let first = self.first_time.get_or_insert(dv.sim_time_ms as u64); + self.test_time_ms = (dv.sim_time_ms as u64).saturating_sub(*first); + + if self.frame_count == 0 { + // Take snapshots so the closure doesn't capture `self` + let saw_static = self.saw_static; + + add_test!(reporter, "First Test Data Frame received", |obs| { + expect!(obs, saw_static, "Static Data should have come first"); + obs.pass(); + }); + self.step = TestStep::CheckStartingValues; + } + println!("Step {:?}", self.step); + match self.step { + TestStep::CheckStartingValues => { + let draw_watts = dv.draw_watts; + self.step = self.check_starting_values(draw_watts); + }, + TestStep::EndAndReport => self.end_and_report(), + _ => {} + } + + self.frame_count += 1; + + } + } + let mut r = DisplayRenderer::new(RenderMode::IntegrationTest); + r.set_test_tap(ITest::new()).unwrap(); + r.run(rx).await; + + } +} +``` +This introduces a few notable changes. + +We've introduced an enum, `TestStep`, that names a series of proposed points in the flow that we wish to make measurements. For now, we are only using the first of these `CheckStartingValues`, but the pattern will remain the same for any subsequent steps. We have a corresponding `check_starting_values` method defined that conducts the actual test. Note the `end_and_report` method also, which is the last step of the flow and signals it is time to report the test results and exit. + +This revised version does little more just yet than our previous one, but it sets the stage for stepwise updates. +`cargo run --features integration-test`: +``` +🚀 Integration test mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start() +spawning controller_core_task +spawning start_charger_task +spawning charger_policy_event_task +spawning integration_listener_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +battery-service DoInit -> Ok(Ack) +🔬 Integration testing starting... +Step CheckStartingValues +Step EndAndReport +================================================== + Test Section Report +[PASS] Static Values received (700ns) +[PASS] First Test Data Frame received (400ns) +[PASS] Check Starting Values (300ns) + + Summary: total=3, passed=3, failed=0 +================================================== +``` +Before we move on with the next steps, let's finish out the perfunctory tasks of verifying our static data and a couple more starting values: + +Replace the current `on_static` method with this one: +```rust + fn on_static(&mut self, sv: &StaticValues) { + let reporter = &mut self.reporter; + let mfg_name = sv.battery_mfr.clone(); + let dev_name = sv.battery_name.clone(); + let chem = sv.battery_chem.clone(); + let cap_mwh = sv.battery_dsgn_cap_mwh; + let cap_mv = sv.battery_dsgn_voltage_mv; + add_test!(reporter, "Static Values received", |obs| { + expect_eq!(obs, mfg_name.trim_end_matches('\0'), "MockBatteryCorp"); + expect_eq!(obs, dev_name.trim_end_matches('\0'), "MB-4200"); + expect_eq!(obs, chem.trim_end_matches('\0'), "LION"); + expect_eq!(obs, cap_mwh, 5000); + expect_eq!(obs, cap_mv, 7800); + }); + self.saw_static = true; + println!("🔬 Integration testing starting..."); + } +``` +and we'll check some more of the starting values. Change the member function `check_starting_values()` to this version: +```rust + fn check_starting_values(&mut self, soc:f32, draw_watts:f32, charge_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + let reporter = &mut self.reporter; + add_test!(reporter, "Check Starting Values", |obs| { + expect_eq!(obs, soc, 100.0); + expect_eq!(obs, draw_watts, 9.4); + expect_eq!(obs, charge_watts, 0.0); + expect_to_decimal!(obs, temp_c, 24.6, 1); + expect_eq!(obs, fan_level, 0); + }); + TestStep::EndAndReport + } +``` +and change the match arm to call it like this: +```rust + TestStep::CheckStartingValues => { + let draw_watts = dv.draw_watts; + let charge_watts = dv.charge_watts; + let temp_c = dv.temp_c; + let soc = dv.soc_percent; + let fan_level = dv.fan_level; + + self.step = self.check_starting_values(soc, draw_watts, charge_watts, temp_c, fan_level); + }, +``` +Now we can be reasonably confident that we are starting out as expected before continuing. + + +> ### A note on test value measurements +> In our starting values test we check the starting temperature pretty closely (to within 1 decimal position), +> but in other tests we look for a more general threshold of range. +> In the case of starting values, we know what this should be because it comes from the scenario configurations, and +> we can be confident in the deterministic outcome. +> In other examples, we can't be entirely sure of the vagaries of time -- even in a simulation, what with differing host computer speeds, drifts in clocks, and inevitable inaccuracies in our simulated physics. So we "loosen the belt" a bit more in these situations. +> +> ---- + + + + + + + + diff --git a/guide_book/src/how/ec/integration/2-move_events.md b/guide_book/src/how/ec/integration/2-move_events.md new file mode 100644 index 0000000..9be6bce --- /dev/null +++ b/guide_book/src/how/ec/integration/2-move_events.md @@ -0,0 +1,675 @@ +# Configurable knobs and controls +We have multiple components, each with different settings, and which interact with one another through definable rules. Defining these adjustable values now helps us to better visualize the infrastructure needed to support their usage and interrelationships. + +## Creating project structure +In your `integration_project` directory, create a `src` directory. +Then create a `main.rs` file within `src`. Leave it empty for now. We'll come to that shortly. +Then create the following folders within `src` that we will use for our collection of configurable settings: +- `config` - This will hold our general configuration knobs and switches in various categories +- `model` - This will hold our behavioral models, specifically our thermal behavior. +- `policy` - This will hold the decision-making policies for the charger and thermal components. +- `state` - This tracks various aspects of component state along the way. + +### The config files +Add the following files within the `src/config` folder: + +`config/policy_config.rs`: +```rust + +use mock_thermal::mock_fan_controller::FanPolicy; + + +#[derive(Clone)] +/// Parameters for the charger *policy* (attach/detach + current/voltage requests). +/// - Attach/Detach uses SOC hysteresis + idle gating (time since last heavy load). +/// - Current requests combine a SOC-taper target, a power-deficit boost, slew limiting, +/// and small-change hysteresis to avoid chatter. +pub struct ChargerPolicyCfg { + /// Instantaneous discharge current (mA, positive = drawing from battery) that qualifies + /// as “heavy use.” When load_ma >= heavy_load_ma, update `last_heavy_load_ms = now_ms`. + pub heavy_load_ma: i32, + + /// Required idle time (ms) since the last heavy-load moment before we may **detach**. + /// Implemented as: `since_heavy >= idle_min_ms`. + pub idle_min_ms: u64, + + /// Minimum time (ms) since the last attach/detach change before we may **(re)attach**. + /// Anti-chatter dwell for entering the attached state. + pub attach_dwell_ms: u64, + + /// Minimum time (ms) since the last attach/detach change before we may **detach**. + /// Anti-chatter dwell for leaving the attached state. + pub detach_dwell_ms: u64, + + /// If `SOC <= attach_soc_max`, we *want to attach* (low side of hysteresis). + pub attach_soc_max: u8, + + /// If `SOC >= detach_soc_min` **and** we’ve been idle long enough, we *want to detach* + /// (high side of hysteresis). Keep `detach_soc_min > attach_soc_max`. + pub detach_soc_min: u8, + + /// Minimum spacing (ms) between policy actions (e.g., recomputing/sending a new capability). + /// Acts like a control-loop cadence limiter. + pub policy_min_interval_ms: u64, + + /// Target voltage (mV) to use while in **CC (constant current)** charging (lower SOC). + /// Typically below `v_float_mv`. + pub v_cc_mv: u16, + + /// Target voltage (mV) for **CV/float** region (higher SOC). Used after CC phase. + pub v_float_mv: u16, + + /// Upper bound (mA) on requested charge current from policy (device caps may further clamp). + pub i_max_ma: u16, + + /// Proportional gain mapping **power deficit** (Watts) → **extra charge current** (mA), + /// to cover system load while attached: `p_boost_ma = clamp(kp_ma_per_w * watts_deficit)`. + pub kp_ma_per_w: f32, + + /// Hard cap (mA) on the proportional “power-boost” term so large deficits don’t overshoot. + pub p_boost_cap_ma: u16, + + /// Maximum rate of change (mA/s) applied by the setpoint slewer. Prevents step jumps + /// and improves stability/realism. + pub max_slew_ma_per_s: u16, + + /// Minimum delta (mA) between current setpoint and new target before updating. + /// If `|target - current| < policy_hysteresis_ma`, do nothing (reduces twitch). + pub policy_hysteresis_ma: u16, +} + +impl Default for ChargerPolicyCfg { + fn default() -> Self { + Self { + heavy_load_ma: 800, idle_min_ms: 3000, attach_dwell_ms: 3000, detach_dwell_ms:3000, + attach_soc_max: 90, detach_soc_min: 95, + + policy_min_interval_ms: 3000, + v_cc_mv: 8300, + v_float_mv: 8400, + + i_max_ma: 4500, + kp_ma_per_w: 50.0, p_boost_cap_ma: 800, + max_slew_ma_per_s: 4000, policy_hysteresis_ma: 50 + } + } +} + +#[derive(Clone)] +/// Parameters for the *policy* (how we choose a fan level based on temperature). +pub struct ThermalPolicyCfg { + /// Lower temperature (°C) where cooling begins (or where we allow stepping *down*). + /// Often the bottom of your control band. Keep < temp_high_on_c. + pub temp_low_on_c: f32, + + /// Upper temperature (°C) where stronger cooling is demanded (or step *up*). + /// Often the top of your control band. Keep > temp_low_on_c. + pub temp_high_on_c: f32, + + /// Fan hysteresis in °C applied around thresholds to prevent chatter. + /// Example: step up when T > (threshold + fan_hyst_c); step down when T < (threshold - fan_hyst_c). + pub fan_hyst_c: f32, + + /// Minimum time (ms) the fan must remain at the current level before another change. + /// Anti-flap dwell; choose ≥ your control loop interval and long enough to feel stable. + pub fan_min_dwell_ms: u64, + + /// The mapping/strategy for levels (e.g., L0..L3) → duty cycle (%), plus any custom rules. + /// Typically defines the % per level and possibly per-level entry/exit thresholds. + pub fan_policy: FanPolicy, +} + +impl Default for ThermalPolicyCfg { + fn default() -> Self { + Self { + temp_low_on_c: 22.0, + temp_high_on_c: 30.0, + fan_hyst_c: 1.5, + fan_min_dwell_ms: 1000, + fan_policy: FanPolicy::default() + } + } +} + +#[derive(Clone, Default)] +/// Combined settings that affect policy +pub struct PolicyConfig { + pub charger: ChargerPolicyCfg, + pub thermal: ThermalPolicyCfg, +} +``` + +`config/sim_config.rs`: +```rust +// src/config/sim_config.rs +#[allow(unused)] +#[derive(Clone)] +/// Parameters for the simple thermal *model* (how temperature evolves). +/// Roughly: T' = (T_ambient - T)/tau_s + k_load_w * P_watts - k_fan_pct * fan_pct +pub struct ThermalModelCfg { + /// Ambient/environment temperature in °C (the asymptote with zero load & zero fan). + pub ambient_c: f32, + + /// Thermal time constant (seconds). Larger = slower temperature response. + /// Used to low-pass (integrate) toward ambient + heat inputs. + pub tau_s: f32, + + /// Heating gain per electrical power (°C/sec per Watt), or folded into your integrator + /// as °C per tick when multiplied by P_w and dt. Higher => load heats the system faster. + /// Typical: start small (e.g., 0.001–0.02 in your dt units) and tune until ramps look plausible. + pub k_load_w: f32, + + /// Cooling gain per fan percentage (°C/sec per 100% fan), or °C per tick when multiplied + /// by (fan_pct/100) and dt. Higher => fan cools more aggressively. + /// Tune so “100% fan” can arrest/ramp down temp under expected max load. + pub k_fan_pct: f32, + + /// Nominal battery/system voltage in mV (for converting current → power when needed). + /// Example: P_w ≈ (load_ma * v_nominal_mv) / 1_000_000. Use an average system/battery voltage. + pub v_nominal_mv: u16, + + /// Maximum discrete fan level your policy supports (e.g., 3 → L0..L3). + /// Used for clamping and for mapping policy levels to a %. + pub max_fan_level: u8, + + /// fractional heat contributions of charger/charging power + /// Rough guide: 5–10% PSU loss + /// °C per Watt of charge power + pub k_psu_loss: f32, + + /// fractional heat contributions of charger/charging power + /// Rough guide: a few % battery heating during charge. + /// °C per Watt of charge power + pub k_batt_chg: f32, +} + + +#[allow(unused)] +#[derive(Clone)] +/// settings applied to the simulator behavior itself +pub struct TimeSimCfg { + /// controls the speed of the simulator -- a multiple of simulated seconds per 1 real-time second. + pub sim_multiplier: f32, +} + +#[allow(unused)] +#[derive(Clone)] +/// parameters that define the capabilities of the integrated charging system +pub struct DeviceCaps { + /// maximum current (mA) of device + pub max_current_ma: u16, // 3000 for mock + /// maximum voltage (mV) of device + pub max_voltage_mv: u16, // 15000 for mock +} + +#[allow(unused)] +#[derive(Clone)] +/// Combined settings that affect the simulation behavior. +pub struct SimConfig { + pub time: TimeSimCfg, + pub thermal: ThermalModelCfg, + pub device_caps: DeviceCaps, +} + +impl Default for SimConfig { + fn default() -> Self { + Self { + time: TimeSimCfg { sim_multiplier: 25.0 }, + thermal: ThermalModelCfg { + ambient_c: 23.0, tau_s: 4.0, k_load_w: 0.20, k_fan_pct: 0.015, + v_nominal_mv: 8300, max_fan_level: 10, + }, + device_caps: DeviceCaps { max_current_ma: 4800, max_voltage_mv: 15000 }, + } + } +} +``` + +`config/ui_config.rs`: +```rust + +#[derive(Clone, PartialEq)] +#[allow(unused)] +/// Defines which types of rendering we can choose from +/// `InPlace` uses ANSI-terminal positioning for a static position display +/// `Log` uses simple console output, useful for tracking record over time. +pub enum RenderMode { InPlace, Log } + +#[derive(Clone)] +#[allow(unused)] +/// Combined UI settings +pub struct UIConfig { + /// InPlace or Log + pub render_mode: RenderMode, + /// Initial load (mA) to apply prior to any interaction + pub initial_load_ma: u16, +} +impl Default for UIConfig { + fn default() -> Self { + Self { render_mode: RenderMode::InPlace, initial_load_ma: 1200 } + } +} +``` +And we need a `mod.rs` file within the folder to bring these together for inclusion: + +`config/mod.rs`: +```rust +// config +pub mod sim_config; +pub mod policy_config; +pub mod ui_config; + +pub use sim_config::SimConfig; +pub use policy_config::PolicyConfig; +pub use ui_config::UIConfig; + +#[derive(Clone, Default)] +pub struct AllConfig { + pub sim: SimConfig, + pub policy: PolicyConfig, + pub ui: UIConfig, +} +``` +A quick scan of these values shows that these represent various values one may want to adjust in order to model different component capabilities, behaviors, or conditions. The final aggregate, `AllConfig` brings all of these together into one nested structure. The `Default` implementations for each simplify the normal setting of these values at construction time. These values can be adjusted to suit your preferences. If you are so inclined, you might even consider importing these values from a configuration file, but we won't be doing that here. + +Now let's continue this pattern for the `policy`, `state`, and `model` categories as well. +`policy/charger_policy.rs`: +```rust +use embedded_services::power::policy::PowerCapability; +use crate::config::policy_config::ChargerPolicyCfg; +use crate::config::sim_config::DeviceCaps; + +pub fn derive_target_ma(cfg: &ChargerPolicyCfg, dev: &DeviceCaps, soc: u8, load_ma: i32) -> u16 { + let i_max = cfg.i_max_ma.min(dev.max_current_ma); + let cover_load = (load_ma + cfg.heavy_load_ma as i32).max(0) as u16; + + // piecewise taper + let soc_target = if soc < 60 { i_max } + else if soc < 85 { (i_max as f32 * 0.80) as u16 } + else if soc < cfg.attach_soc_max { (i_max as f32 * 0.60) as u16 } + else if soc < 97 { (i_max as f32 * 0.35) as u16 } + else { (i_max as f32 * 0.15) as u16 }; + + cover_load.max(soc_target).min(i_max) +} + +pub fn p_boost_ma(kp_ma_per_w: f32, p_boost_cap_ma: u16, watts_deficit: f32) -> u16 { + (kp_ma_per_w * watts_deficit.max(0.0)).min(p_boost_cap_ma as f32) as u16 +} + +pub fn slew_toward(current: u16, target: u16, dt_s: f32, rate_ma_per_s: u16) -> u16 { + let max_delta = (rate_ma_per_s as f32 * dt_s) as i32; + let delta = target as i32 - current as i32; + if delta.abs() <= max_delta { target } + else if delta > 0 { (current as i32 + max_delta) as u16 } + else { (current as i32 - max_delta) as u16 } +} + +pub fn build_capability(cfg: &ChargerPolicyCfg, dev: &DeviceCaps, soc: u8, current_ma: u16) -> PowerCapability { + let v_target = if soc < cfg.attach_soc_max { cfg.v_cc_mv } else { cfg.v_float_mv }; + PowerCapability { + voltage_mv: v_target.min(dev.max_voltage_mv), + current_ma: current_ma.min(dev.max_current_ma), + } +} + +pub struct AttachDecision { + pub attach: bool, // true=attach, false=detach + pub do_change: bool, +} + +#[inline] +fn dwell_ok(was_attached: bool, since_change_ms: u64, cfg: &ChargerPolicyCfg) -> bool { + if was_attached { + since_change_ms >= cfg.detach_dwell_ms + } else { + since_change_ms >= cfg.attach_dwell_ms + } +} + +#[inline] +fn ms_since(t:u64, now:u64) -> u64 { + now - t +} + +pub fn decide_attach( + cfg: &ChargerPolicyCfg, + was_attached: bool, + soc: u8, // 0..=100 + last_psu_change_ms: u64, // when we last toggled attach/detach + last_heavy_load_ms: u64, // when we last saw heavy load + now_ms: u64, +) -> AttachDecision { + let since_change = ms_since(last_psu_change_ms, now_ms); + let since_heavy = ms_since(last_heavy_load_ms, now_ms); + + // Hysteresis-based targets + let want_attach = soc <= cfg.attach_soc_max; + let want_detach = (soc >= 100 && was_attached) || (soc >= cfg.detach_soc_min && since_heavy >= cfg.idle_min_ms); + + let can_change = dwell_ok(was_attached, since_change, cfg); + + // Priority rules: + // 1) If we are attached and conditions say detach, and dwell is satisfied -> detach. + // 2) If we are detached and conditions say attach, and dwell is satisfied -> attach. + // 3) Otherwise no-op. + if was_attached { + if want_detach && can_change { + return AttachDecision { attach: false, do_change: true }; + } + } else { + if want_attach && can_change { + return AttachDecision { attach: true, do_change: true }; + } + } + + AttachDecision { attach: was_attached, do_change: false } +} +``` + +`policy/thermal_governor.rs`: +```rust + +use ec_common::events::{CoolingRequest, ThresholdEvent}; +use crate::state::ThermalState; + +pub struct ThermDecision { + pub threshold_event: ThresholdEvent, + pub cooling_request: Option, + pub hi_latched: bool, + pub lo_latched: bool, + pub did_step: bool, +} + +pub fn process_sample( + temp_c: f32, + hi_latched: bool, lo_latched: bool, + hi_on: f32, lo_on: f32, + hyst: f32, + last_fan_change_ms: u64, + dwell_ms: u64, + now_ms: u64, + mut state: ThermalState +) -> ThermDecision { + let hi_off = hi_on - hyst; + let lo_off = lo_on + hyst; + + let mut hi = hi_latched; + let mut lo = lo_latched; + if !hi && temp_c >= hi_on { hi = true; } + if hi && temp_c <= hi_off { hi = false; } + if !lo && temp_c <= lo_on { lo = true; } + if lo && temp_c >= lo_off { lo = false; } + + // Compute a *non-overlapping* neutral zone around the midpoint + let mid = 0.5 * (hi_on + lo_on); + let center_hyst = 0.5 * hyst; // or a new cfg.center_hyst_c if you want it independent + + let dwell_ok = now_ms - last_fan_change_ms >= dwell_ms; + + let want_dir: i8 = + if hi { 1 } + else if lo { -1 } + else if temp_c > mid + center_hyst { 1 } + else if temp_c < mid - center_hyst { -1 } + else { 0 }; + + let dir_dwell_ms = dwell_ms / 2; // or a separate cfg.fan_dir_dwell_ms + let reversing = want_dir != 0 && want_dir == -state.last_dir; + let dir_ok = !reversing || (now_ms - state.last_dir_change_ms >= dir_dwell_ms); + + let mut threshold_event = ThresholdEvent::None; + let mut cooling_request = None; + let mut did_step = false; + + if dwell_ok && dir_ok { + if want_dir > 0 { cooling_request = Some(CoolingRequest::Increase); did_step = true; } + if want_dir < 0 { cooling_request = Some(CoolingRequest::Decrease); did_step = true; } + } + + if did_step { + state.last_fan_change_ms = now_ms; + if want_dir != 0 && want_dir != state.last_dir { + state.last_dir = want_dir; + state.last_dir_change_ms = now_ms; + } + } + + + // Edge-generated events (for logging/telemetry), but not the only time we step + if hi && !hi_latched { + threshold_event = ThresholdEvent::OverHigh; + } else if lo && !lo_latched { + threshold_event = ThresholdEvent::UnderLow; + } + + ThermDecision { threshold_event, cooling_request, hi_latched: hi, lo_latched: lo, did_step } +} +``` + +`policy/mod.rs`: +```rust +//policy +pub mod charger_policy; +pub mod thermal_governor; +``` +These policy implementations define functions that control the decision rules for the charger and thermal components. + +`state/charger_state.rs`: +```rust + +pub struct ChargerState { + pub requested_ma: u16, + pub last_policy_sent_at_ms: u64, + pub was_attached: bool, + pub last_psu_change_ms: u64, + pub last_heavy_load_ms: u64, +} + +impl Default for ChargerState { + fn default() -> Self { + Self { + requested_ma: 0, + last_policy_sent_at_ms: 0, + was_attached: false, + last_psu_change_ms: 0, + last_heavy_load_ms: 0, + } + } +} +``` + +`state/thermal_state.rs`: +```rust + +#[derive(Copy, Clone)] +pub struct ThermalState { + pub fan_level: u8, + pub hi_latched: bool, + pub lo_latched: bool, + pub last_fan_change_ms: u64, + pub last_dir: i8, + pub last_dir_change_ms: u64 +} + +impl Default for ThermalState { + fn default() -> Self { + Self { + fan_level: 0, + hi_latched: false, + lo_latched: false, + last_fan_change_ms: 0, + last_dir: 0, + last_dir_change_ms: 0 + } + } +} + +``` + +`state/sim_state.rs`: +```rust +use embassy_time::Instant; + +pub struct SimState { + pub last_update: Instant, +} + +impl Default for SimState { + fn default() -> Self { + Self { last_update: Instant::now() } + } +} +``` + +`state/mod.rs`: +```rust +// state +pub mod charger_state; +pub mod thermal_state; +pub mod sim_state; + +pub use charger_state::ChargerState; +pub use thermal_state::ThermalState; +pub use sim_state::SimState; +``` +These states are used to track the current condition of the simulation and its components in action over time. + +`model/thermal_model.rs`: +```rust +use crate::config::sim_config::ThermalModelCfg; + +pub fn step_temperature( + t: f32, + load_ma: i32, + fan_level: u8, + cfg: &ThermalModelCfg, + dt_s: f32, + chg_w: f32, // NEW: charge power in Watts (0 if not charging) +) -> f32 { + let load_w = (load_ma.max(0) as f32) * (cfg.v_nominal_mv as f32) / 1_000_000.0; + + // Fractional heat contributions + let psu_heat_w = cfg.k_psu_loss * chg_w; // DC-DC inefficiency + board losses + let batt_heat_w = cfg.k_batt_chg * chg_w; // battery internal resistance during charge + + let fan_pct = 100.0 * (fan_level as f32) / (cfg.max_fan_level as f32).max(1.0); + + // Combined drive: ambient + load heat + charger/battery heat - fan cooling + let drive = cfg.ambient_c + + cfg.k_load_w * load_w + + psu_heat_w + + batt_heat_w + - cfg.k_fan_pct * fan_pct; + + let alpha = (dt_s / cfg.tau_s).clamp(0.0, 1.0); + (t + alpha * (drive - t)).max(cfg.ambient_c) +} +``` +`model/mod.rs`: +```rust +// model +pub mod thermal_model; +``` +The thermal model is used to express the physical effects of the cooling airflow from the fan. You will recall that the physical effects of the virtual battery have already been implemented via the `tick()` method of `VirtualBattery`, which also computes a temperature generated by the battery itself. This thermal model complements this in this integrated simulation by applying a cooling effect function. + + + +## Consolidating events +Earlier we mentioned that we would simplify our comms implementation in this exercise by consolidating the message types onto a single communication channel bus. + +> ### Why one bus? +> - easier tracing +> - simpler buffering +> - less churn when adding new message types +> ---- + +Let's define a single enum to help us with that now: + +Create `events.rs` with this content: +```rust +use embedded_services::power::policy::charger::ChargerEvent; +use ec_common::events::ThermalEvent; +use embedded_services::power::policy::PowerCapability; + +#[allow(unused)] +#[derive(Debug)] +pub enum BusEvent { + Charger(ChargerEvent), + ChargerPolicy(PowerCapability), // associates with PolicyEvent::PowerConfiguration for our handling + Thermal(ThermalEvent), +} +``` +Notice in this code it refers to `ec_common::events::ThermalEvent` but we don't have our `ThermalEvent` in `ec_common`. We had defined that as part of our `thermal_project` exercise, but did not add it to the `ec_common` `events.rs` file. We can copy the definition from there and add it now, so that our new `ec_common/src/events.rs` file is a common location for events defined up to this point, and looks like this: +```rust + +//! Common types and utilities for the embedded controller (EC) ecosystem. +/// BatteryEvent is defined at `battery_service::context::BatteryEvent` +/// ChargerEvent is defined at `embedded_services::power::policy::charger::ChargerEvent` + +/// -------------------- Thermal -------------------- + +/// Events to announce thermal threshold crossings +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThresholdEvent { + None, + OverHigh, + UnderLow +} + +/// Request to increase or decrease cooling efforts +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoolingRequest { + Increase, + Decrease +} + +/// Resulting values to apply to accommodate request +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CoolingResult { + pub new_level: u8, + pub target_rpm_percent: u8, + pub spinup: Option, +} + +/// One-shot spin-up hint: force RPM for a short time so the fan actually starts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpinUp { + pub rpm: u16, + pub hold_ms: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThermalEvent { + TempSampleC100(u16), // (int) temp_c * 100 + Threshold(ThresholdEvent), + CoolingRequest(CoolingRequest) +} +``` +Now all of our event messaging can be referred to from the single enumeration source `BusEvent`, and our handlers can dispatch accordingly. + + +Edit `thermal_project/mock_thermal/src/mock_sensor_controller.rs` to remove the definition of `ThresholdEvent` there, and add the following import: + +```rust +use ec_common::events::ThresholdEvent; +``` + +Edit `thermal_project/mock_thermal/src/mock_fan_controller.rs` to remove the definitions of `CoolingRequest`, `CoolingResult`, and `SpinUp`, and add the following imports: + +```rust +use ec_common::events::{CoolingRequest, CoolingResult, SpinUp}; +``` + +We also need to add this to the `lib.rs` file of `ec_common` to make these types available to the rest of the crate: + +```rust +pub mod mutex; +pub mod mut_copy; +pub mod espi_service; +pub mod fuel_signal_ready; +pub mod test_helper; +pub mod events; +``` + +Try a `cargo build` (or a `cargo check`) in `thermal_project` to ensure that everything is still compiling correctly. If you have not made any other changes there, it should compile without errors. diff --git a/guide_book/src/how/ec/integration/20-charger_attachment.md b/guide_book/src/how/ec/integration/20-charger_attachment.md new file mode 100644 index 0000000..1d00b3d --- /dev/null +++ b/guide_book/src/how/ec/integration/20-charger_attachment.md @@ -0,0 +1,67 @@ +# Checking Charger Attachment + +Let's continue on with the next step we've outlined in our `TestStep` series: `TestStep::CheckChargerAttach`. + +To do this, create a new member function for this: +```rust + fn check_charger_attach(&mut self, mins_passed: f32, soc:f32, _draw_watts:f32, charge_watts:f32) -> TestStep{ + let reporter = &mut self.reporter; + // Fail if we don't see our starting conditions within a reasonable time + if mins_passed > 30.0 { // should occur before 30 minutes simulation time + add_test!(reporter, "Attach Charger", |obs| { + obs.fail("Time expired waiting for attach"); + }); + } + // wait until we see evidence of charger attachment + if charge_watts == 0.0 { + return TestStep::CheckChargerAttach; // stay on this task + } + add_test!(reporter, "Check Charger Attachment", |obs| { + expect!(obs, soc <= 90.0, "Attach expected <= 90% SOC"); + }); + TestStep::EndAndReport // go to next step + } +``` +This is a little different because it first checks for qualifying (or disqualifying error) conditions before it begins the actual test closure. +First, it checks to see if we've timed out -- using simulation time, and assuming the starting values that we've already verified, we expect the battery to discharge to the attach point in under 30 minutes. If this condition fails, we create a directly failing test to report it. +We then check to see if the charger is attached, which is evidenced by `charge_watts > 0.0` until this is true, we return `TestStep::CheckChargerAttach` so that we continue to be called each frame until then. +Once these conditional checks are done, we can test what it means to be in attached state and proceed to the next step, which in this case is `EndAndReport` until we add another test. + +On that note, edit the return of `check_starting_values()` to now be `TestStep::CheckChargerAttach`. + +Now, in the match arms for this, add this caller: +```rust + TestStep::CheckChargerAttach => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let soc = dv.soc_percent; + let draw_watts = dv.draw_watts; + let charge_watts = dv.charge_watts; + + self.step = self.check_charger_attach(mins_passed, soc, draw_watts, charge_watts); + + }, +``` + +finally, remove this `println!` because these will start to become annoying at this point: +```rust + println!("Step {:?}", self.step); +``` + +and when run with `cargo run --features integration-test` you should see: +``` +🔬 Integration testing starting... + ☄ attaching charger +================================================== + Test Section Report +[PASS] Static Values received +[PASS] First Test Data Frame received +[PASS] Check Starting Values +[PASS] Check Charger Attachment + + Summary: total=4, passed=4, failed=0 +================================================== +``` + +Next, we'll look at what the effects of increasing the system load have to our scenario, but first we need to provide a mechanism for that. + + diff --git a/guide_book/src/how/ec/integration/21-affecting_change.md b/guide_book/src/how/ec/integration/21-affecting_change.md new file mode 100644 index 0000000..49ed631 --- /dev/null +++ b/guide_book/src/how/ec/integration/21-affecting_change.md @@ -0,0 +1,252 @@ +# Affecting Change in the tests + +For our next test, we want to raise the system load and then see how that affects temperature (it should rise). + +We don't currently have a way to tell the simulation to raise the load. But in interactive mode we can, and we did that by sending `InteractionEvent` messages. Let's do that here. We'll need to pass in the `InteractionChannelWrapper` we need for sending these messages into the `interaction_test()` function. + +Start by adding these imports: +```rust + use crate::entry::InteractionChannelWrapper; + use ec_common::espi_service::EventChannel; + use crate::events::InteractionEvent; +``` +Then, change the signature for `interaction_test()` to accept the new parameter: +```rust + #[embassy_executor::task] + pub async fn integration_test(rx: &'static DisplayChannelWrapper, tx:&'static InteractionChannelWrapper) { +``` +Now, unlike the `rx` parameter that we use within the body of the function, we need this `tx` parameter available to us while we are in the test code -- and therefore the `ITest` structure itself, so we need to add it as a member and pass it in on the constructor: + +```rust + struct ITest { + reporter: TestReporter, + tx: &'static InteractionChannelWrapper, + ... + } +``` +and +```rust + impl ITest { + pub fn new(tx:&'static InteractionChannelWrapper) -> Self { + let mut reporter = TestReporter::new(); + reporter.start_test_section(); // start out with a new section + Self { + reporter, + tx, + ... +``` +and pass `tx` in the `ITest` constructor in this code at the bottom of the `integration_test()` function: +```rust + let mut r = DisplayRenderer::new(RenderMode::IntegrationTest); + r.set_test_tap(ITest::new(tx)).unwrap(); + r.run(rx).await; +``` + +Note that the `rx` (Display) Channel is consumed entirely within the `DisplayRenderer` `run` loop, whereas our `tx` (Interaction) Channel must be available to us in `ITest` for ad-hoc sending of `InteractionEvent` messages within our test steps, thus the way we've bifurcated the usage of these here. + +Now we are set up to call on interaction event to increase and decrease the load, as we will use in the next test. + +Our `TestStep` enum for this is `RaiseLoadAndCheckTemp`. +Create a new member function to handle this: + +```rust +fn raise_load_and_check_temp(&mut self, mins_passed:f32, draw_watts: f32, temp_c:f32) -> TestStep { + let reporter = &mut self.reporter; + + TestStep::EndAndReport +} +``` +We'll fill it out later. First, we need to add some helper members we can use to track time and temperature. + +Add these members to the `ITest` struct: +```rust + mark_time: Option, + mark_temp: Option, +``` +and initialize them as `None`: +```rust + mark_time: None, + mark_temp: None, +``` +Now, fill out our `raise_load_and_check_temp` function to look like this: +```rust + fn raise_load_and_check_temp(&mut self, mins_passed:f32, draw_watts: f32, temp_c:f32) -> TestStep { + + let reporter = &mut self.reporter; + + if self.mark_time == None { + self.mark_time = Some(mins_passed); + self.mark_temp = Some(temp_c); + } + + if draw_watts < 20.0 { // raise to something above 20 then stop pumping it up + let _ = self.tx.try_send(InteractionEvent::LoadUp); + return TestStep::RaiseLoadAndCheckTemp + } + let mt = *self.mark_time.get_or_insert(mins_passed); + let time_at_charge = if mins_passed > mt { mins_passed - mt } else { 0.0 }; + if time_at_charge > 0.5 { // after about 30 seconds, check temperature + let temp_raised = self.mark_temp.map_or(0.0, |mt| if temp_c > mt { temp_c - mt } else { 0.0 }); + add_test!(reporter, "Temperature rises on charge", |obs| { + expect!(obs, temp_raised > 1.5, "Temp should rise noticeably"); + }); + } else { + // keep going + return TestStep::RaiseLoadAndCheckTemp + } + // reset in case we want to use these again later + self.mark_temp = None; + self.mark_time = None; + TestStep::EndAndReport + } +``` +What we do here is mark the time when we first get in, then we bump up the the load using our new `tx` member until we see that the load is something above 20w. At that point we check the time to see if at least 1/2 a minute has passed. Until these conditions are met, we keep returning `TestStep::RaiseLoadAndCheckTemp` to keep us evaluating this state. Once there, we check how high the temperature has risen since the last check, relative to the marked baseline. We expect it to be around 2 degrees, give or take, so we'll check for 1.5 degrees or more as our test. We then go to the next step (for now, `EndAndReport`), but before we do we reset our `Option` marks in case we want to reuse them in subsequent tests. + +Remember to change the return value of `check_charger_attach` to go to `TestStep::RaiseLoadAndCheckTemp` also, or this test won't fire. + +Then add the calling code in the match arms section below: +```rust + TestStep::RaiseLoadAndCheckTemp => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let load_watts = dv.draw_watts; + let temp_c = dv.temp_c; + + self.step = self.raise_load_and_check_temp(mins_passed, load_watts, temp_c); + }, +``` + +## Checking the Fan +The next test we'll create is similar, but in this case, we'll raise the load (and heat) significantly enough for the system fan to kick in. + +Create the member function we'll need for this. it will look much like the previous one in many ways: +```rust + fn raise_load_and_check_fan(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + let reporter = &mut self.reporter; + + // record time we started this + if self.mark_time == None { + self.mark_time = Some(mins_passed); + } + + if draw_watts < 39.0 { // raise to maximum + let _ = self.tx.try_send(InteractionEvent::LoadUp); + return TestStep::RaiseLoadAndCheckFan + } + let mt = *self.mark_time.get_or_insert(mins_passed); + let time_elapsed = if mins_passed > mt { mins_passed - mt } else { 0.0 }; + if time_elapsed > 0.25 && fan_level == 0 { // this should happen relatively quickly (about 15 seconds of sim time) + add_test!(reporter, "Timed out waiting for fan", |obs| { + obs.fail("Time expired"); + }); + return TestStep::EndAndReport // end the test now on timeout error + } + + if fan_level > 0 { + add_test!(reporter, "Fan turns on", |obs| { + obs.pass(); + }); + add_test!(reporter, "Temperature is warm", |obs| { + expect!(obs, temp_c >= 28.0, "temp below fan on range"); + }); + } else { + // keep going + return TestStep::RaiseLoadAndCheckFan + } + // reset in case we want to use these again later + self.mark_temp = None; + self.mark_time = None; + TestStep::EndAndReport + } +``` +add the calling case to the match arm: +```rust + TestStep::RaiseLoadAndCheckFan => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let draw_watts = dv.draw_watts; + let temp_c = dv.temp_c; + let fan_level = dv.fan_level; + + self.step = self.raise_load_and_check_fan(mins_passed, draw_watts, temp_c, fan_level); + }, + +``` +Don't forget to update the next step return of the previous step so that it carries forward to this one. + +## Time to Chill +Great! Now, let's make sure the temperature goes back down with less demand on the system and that the fan backs off when cooling is complete. + +Create the member function +```rust + fn lower_load_and_check_cooling(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + let reporter = &mut self.reporter; + + // record time and temp when we started this + if self.mark_time == None { + self.mark_time = Some(mins_passed); + self.mark_temp = Some(temp_c); + } + + // drop load back to low + if draw_watts > 10.0 { + let _ = self.tx.try_send(InteractionEvent::LoadDown); + return TestStep::LowerLoadAndCheckCooling + } + // wait a bit + let mark_time = *self.mark_time.get_or_insert(mins_passed); + let diff = mins_passed - mark_time; + if diff > 60.0 { // wait for an hour for it to cool all the way + return TestStep::LowerLoadAndCheckCooling + } + + add_test!(reporter, "Cooled", |obs| { + expect!(obs, draw_watts < 10.0, "Load < 10 W"); + println!("temp is {}", temp_c); + expect!(obs, temp_c < 25.5, "Temp is < 25.5"); + }); + add_test!(reporter, "Fan turns off", |obs| { + expect_eq!(obs, fan_level, 0); + }); + // reset in case we want to use these again later + self.mark_temp = None; + self.mark_time = None; + TestStep::EndAndReport + } + +``` +and the caller in the match arm: +```rust + TestStep::LowerLoadAndCheckCooling => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let draw_watts = dv.draw_watts; + let temp_c = dv.temp_c; + let fan_level = dv.fan_level; + + self.step = self.lower_load_and_check_cooling(mins_passed, draw_watts, temp_c, fan_level); + }, +``` +And again, remember to update the return value for the next step of the `load_and_check_fan` method to be `TestStep::LowerLoadAndCheckCooling` so that it chains to this one properly. + +Your `cargo run --features integration-test` should now complete in about 40 seconds and look like this (your output timing may vary slightly): +``` +================================================== + Test Section Report + Duration: 38.2245347s + +[PASS] Static Values received +[PASS] First Test Data Frame received +[PASS] Check Starting Values +[PASS] Check Charger Attachment +[PASS] Temperature rises on charge +[PASS] Temperature is warm +[PASS] Fan turns on +[PASS] Cooled +[PASS] Fan turns off + + Summary: total=9, passed=9, failed=0 +================================================== +``` + + + + + diff --git a/guide_book/src/how/ec/integration/22-summary_thoughts.md b/guide_book/src/how/ec/integration/22-summary_thoughts.md new file mode 100644 index 0000000..ad321e6 --- /dev/null +++ b/guide_book/src/how/ec/integration/22-summary_thoughts.md @@ -0,0 +1,31 @@ +# Summary Thoughts + +Congratulations on your success in building and integrating, understanding, and testing virtual components using the ODP framework! + +There are, of course, a number of things that one would do differently if they were working with actual hardware and building for an actual system. + +For starters, much of the behavioral code we created in these exercises needed to substitute simulated time and physical responses that would "just happen" with real hardware in real time, and real-world physics may differ from our simplified models here. + +We use `println!` pretty liberally in our examples -- this is fine since we are building for a std environment with these apps currently, but when migrating to a target build, these will need to replaced by `log::debug!` or `info!` and limited in context. + +With the simplistic integration we have here, the battery is always the source of power -- it will charge, but if the load exceeds the charger ability, the battery will eventually fail even when the device is presumably plugged in. This is an artifact of our simplified model. A real Embedded Controller integrations power-path control so that mains input can bypass the battery when not available. Readers are encouraged to look into the usb-pd support found in the [ODP usb-pd repository](https://github.com/OpenDevicePartnership/embedded-usb-pd) for resources that can be used in extending the integration to support a "plugged-in" mode. + +## Where do we go from here? + +There are some key steps ahead before one can truly claim to have an EC integration ready: +- __Hardware__ - We have not yet targeted for actual embedded hardware -- whether simulated behavior is used or not -- that is coming up in the next set of exercises. This could target an actual hardware board or perhaps a QEMU virtual system as an intermediary step. +- __Standard Bus Interface__ - To connect the EC into a system, we would need to adopt a standard bus interface -- most likely __ACPI__ +- __Security__ - We have conceptually touched upon security, but have not made any implementation efforts to support such mechanisms. A real-world system _must_ address these. In environments that support it, a Hafnium-based hypervisor implementation for switching EC services into context is recommended. + +## The ODP EC Test App +Once a complete EC is constructed, there is a very nice test app produced by the ODP that can be used to validate that the ACPI plumbing is correct and the EC responds to calls with the expected arguments in and return values back. + +[ODP ec-test-app](https://github.com/OpenDevicePartnership/ec-test-app) + +At this point, you have the building blocks in hand to extend your virtual EC toward this validation path by adding ACPI plumbing on top of the Rust components we've built and exposing them in a QEMU or hardware container. + +The ec-test-app repo even includes sample ACPI tables (thermal + notification examples) to show how the methods are expected to be defined. That could be a starting point for the essential bridge between the Rust-based EC simulation examples we've worked with, and the Windows validation world for a true device. + + + + diff --git a/guide_book/src/how/ec/integration/3-better_alloc.md b/guide_book/src/how/ec/integration/3-better_alloc.md new file mode 100644 index 0000000..e2ceb7c --- /dev/null +++ b/guide_book/src/how/ec/integration/3-better_alloc.md @@ -0,0 +1,27 @@ +# Improving allocation strategies +In all of our previous examples, we have used the `StaticCell` type to manage our component allocations. This has worked well for our simple examples, but it was never the best approach. Most notably, it forces us use the `duplicate_static_mut!` macro that uses declared `unsafe` casts to allow us to borrow a mutable reference more than once. This is not a good practice, and we should avoid it if possible. +Fortunately, there is an alternative. It's not perfect, but it does allow us to resolve a static with more than a one-time call to `init` the way `StaticCell` does. `OnceLock` is a type that is defined in both `std::sync` and in `embassy::sync`. The `embassy` version is designed to work in an embedded context, and supports an asynchronous context, so we will use this version for our examples. +## Using `OnceLock` +The `OnceLock` type is a synchronization primitive that allows us to initialize a value once, and then access it multiple times. While this might seem to be the obvious alternative to `StaticCell`, it does have some limitations. Most notably, it does not allow us to borrow a mutable reference to the value after it has been initialized. This means that we cannot use it to manage mutable state in the same way that we do with `StaticCell`. So if we need more than one mutable reference to a value, we would still need to use `StaticCell` + `duplicate_static_mut!` or some other approach. + +Fortunately, we have another approach in mind. + +### Changing to `OnceLock` here +In earlier examples we used `StaticCell` (and oftentimes `duplicate_static_mut!`) to construct global singletons and pass `&'static mut references` into tasks. That worked in context, but it becomes easy to paint oneself into a corner: once `&'static mut` is handed out, it can be tempting to duplicate it, which breaks the `unsafe` guarantees and can violate Rust’s aliasing rules. + +`embassy_sync::OnceLock` provides a safer pattern for most globals. It lets us initialize a value exactly once (`get_or_init`) and __await its availability__ from any task (`get().await`) - avoiding the need for separate 'ready' signals. Combined with interior mutability (`Mutex`), we can share mutable state safely across tasks without ever forging multiple `&'static mut` aliases. + +> ## OnceLock vs. StaticCell +> - `StaticCell` provides a mutable reference. A mutable reference may be more useful for accessing internals. +> - `OnceLock` provides a non-mutable reference. It may not be as useful, but can be passed about freely +> - a `OnceLock` containing a `Mutex` to a `StaticCell` entity may be passed around freely and the mutex can resolve a mutable reference. +> ----- + + +We still keep `StaticCell` for the cases where a library requires a true `&'static mut` for its entire lifetime. Everywhere else, `OnceLock + Mutex` is simpler, safer, and matches Embassy’s concurrency model. + +We will be switching to this pattern in our examples going forward, but we will not necessarily update previous usage in the previously existing example code. +Consider the old patterns we have learned up to now to be deprecated. This new paradigm can be a little awkward to bend one's head around at first, but the simplicity and safety of the end result is undeniable. + + + diff --git a/guide_book/src/how/ec/integration/4-update_controller.md b/guide_book/src/how/ec/integration/4-update_controller.md new file mode 100644 index 0000000..56816a1 --- /dev/null +++ b/guide_book/src/how/ec/integration/4-update_controller.md @@ -0,0 +1,450 @@ +# Updating the Controller construction +Continuing our revisions to eliminate the unnecessary use of `duplicate_static_mut!` and adhere to a more canonical pattern that respects single ownership rules, we need to update the constructor for our component Controllers. +We also want to remove the unnecessary use of Generics in the design of these controllers. We are only creating one variant of controller, there is no need to specify in a generic component in these cases, and generics are not only awkward to work with, they come with a certain amount of additional overhead. This would be fine if we were actually taking advantage of different variant implementations, but since we are not, let's eliminate this now and simplify our design as long as we are making changes anyway. + +## Updating the `MockChargerController` Constructor +The `MockChargerController` currently takes both a `MockCharger` and a `MockChargerDevice` parameter. The Controller doesn't actually use the `Device` context -- The `Device` is used to register the component with the service separately, but the component passed to the Controller must be the same as the one registered. + +For the `MockBatteryController`, we got around this by not passing the `MockBatteryDevice`, since it isn't used. For the thermal components, `MockSensorController` and `MockFanController` are passed only the component `Device` instance and the component reference is extracted from here. + +This latter approach is a preferable pattern because it ensures that the same component instance used for registration is also the one provided to the controller. + +We use the `get_internals()` method to return both the component and the `Device` instances instead of simply `inner_charger` because splitting the reference avoids inherent internal borrows on the same mutable 'self' reference. + +Update `charger_project/mock_charger/src/mock_charger_controller.rs` so that the `MockChargerController` definition itself looks like this: + +```rust +pub struct MockChargerController { + pub charger: &'static mut MockCharger, + pub device: &'static mut Device +} + +impl MockChargerController +{ + pub fn new(device: &'static mut MockChargerDevice) -> Self { + let (charger, device) = device.get_internals(); + Self { charger, device } + } +} +``` +and then replace any references in the code of +```rust +self.device.inner_charger() +``` +to become +```rust +self.charger +``` +and lastly, change +```rust +let inner = controller.charger; +``` +to become +```rust +let inner = &controller.charger; +``` +to complete the revisions for `MockChargerController`. + +>📌 Why does `get_internals()` work where inner_charger() fails? +> +> This boils down to how the borrow checker sees the lifetime of the borrows. +> +> `inner_charger()` returns only `&mut MockCharger`,but the MockChargerDevice itself is still alive in the same scope. If you then also try to hand out `&mut Device` later, Rust sees that as two overlapping mutable borrows of the same struct, which is illegal. +> +> `get_internals()` instead performs both borrows inside the same method call and returns them as a tuple. +> +> This is a pattern the compiler can prove safe: it knows exactly which two disjoint fields are being borrowed at once, and it enforces that they don’t overlap. +> +> This is why controllers like our `MockSensorController`, `MockFanController`, and now `MockChargerController` can be cleanly instantiated with `get_internals()`. The `MockBatteryController` happens not to need this because it never touches the `Device` half of `MockBatteryDevice` — it only needs the component itself. + +### The other Controllers +In our `MockSensorController` and `MockFanController` definitions, we did not make our `Device` or component members accessible, so we will change those now to do that and make them public: + +In `mock_sensor_controller.rs`: +```rust +pub struct MockSensorController { + pub sensor: &'static mut MockSensor, + pub device: &'static mut Device +} + +/// +/// Temperature Sensor Controller +/// +impl MockSensorController { + pub fn new(device: &'static mut MockSensorDevice) -> Self { + let (sensor, device) = device.get_internals(); + Self { + sensor, + device + } + } + +``` + +In `mock_fan_controller.rs`: +```rust +pub struct MockFanController { + pub fan: &'static mut MockFan, + pub device: &'static mut Device +} + +/// Fan controller. +/// +/// This type implements [`embedded_fans_async::Fan`] and **inherits** the default +/// implementations of [`Fan::set_speed_percent`] and [`Fan::set_speed_max`]. +/// +/// Those methods are available on `MockFanController` without additional code here. +impl MockFanController { + pub fn new(device: &'static mut MockFanDevice) -> Self { + let (fan, device) = device.get_internals(); + Self { + fan, + device + } + } +``` + +We mentioned that we originally implemented `MockBatteryController` as being constructed without a `Device` element, but we _will_ need to access this device context later, so we should expose that as public member in the same way. While we are at it, we should also eliminate the generic design of the structure definition, since it is only adding unnecessary complexity and inconsistency. + +Update `battery_project/mock_battery/mock_battery_component.rs` so that it now looks like this (consistent with the others): + +```rust +use battery_service::controller::{Controller, ControllerEvent}; +use battery_service::device::{DynamicBatteryMsgs, StaticBatteryMsgs}; +use embassy_time::{Duration, Timer}; +use crate::mock_battery::{MockBattery, MockBatteryError}; +use crate::mock_battery_device::MockBatteryDevice; +use embedded_services::power::policy::device::Device; +use embedded_batteries_async::smart_battery::{ + SmartBattery, ErrorType, + ManufactureDate, SpecificationInfoFields, CapacityModeValue, CapacityModeSignedValue, + BatteryModeFields, BatteryStatusFields, + DeciKelvin, MilliVolts +}; + +pub struct MockBatteryController { + /// The underlying battery instance that this controller manages. + pub battery: &'static mut MockBattery, + pub device: &'static mut Device + +} + +impl MockBatteryController +{ + pub fn new(battery_device: &'static mut MockBatteryDevice) -> Self { + let (battery, device) = battery_device.get_internals(); + Self { + battery, + device + } + } +} + +impl ErrorType for MockBatteryController +{ + type Error = MockBatteryError; +} +impl SmartBattery for MockBatteryController +{ + async fn temperature(&mut self) -> Result { + self.battery.temperature().await + } + + async fn voltage(&mut self) -> Result { + self.battery.voltage().await + } + + async fn remaining_capacity_alarm(&mut self) -> Result { + self.battery.remaining_capacity_alarm().await + } + + async fn set_remaining_capacity_alarm(&mut self, _: CapacityModeValue) -> Result<(), Self::Error> { + self.battery.set_remaining_capacity_alarm(CapacityModeValue::MilliAmpUnsigned(0)).await + } + + async fn remaining_time_alarm(&mut self) -> Result { + self.battery.remaining_time_alarm().await + } + + async fn set_remaining_time_alarm(&mut self, _: u16) -> Result<(), Self::Error> { + self.battery.set_remaining_time_alarm(0).await + } + + async fn battery_mode(&mut self) -> Result { + self.battery.battery_mode().await + } + + async fn set_battery_mode(&mut self, _: BatteryModeFields) -> Result<(), Self::Error> { + self.battery.set_battery_mode(BatteryModeFields::default()).await + } + + async fn at_rate(&mut self) -> Result { + self.battery.at_rate().await + } + + async fn set_at_rate(&mut self, _: CapacityModeSignedValue) -> Result<(), Self::Error> { + self.battery.set_at_rate(CapacityModeSignedValue::MilliAmpSigned(0)).await + } + + async fn at_rate_time_to_full(&mut self) -> Result { + self.battery.at_rate_time_to_full().await + } + + async fn at_rate_time_to_empty(&mut self) -> Result { + self.battery.at_rate_time_to_empty().await + } + + async fn at_rate_ok(&mut self) -> Result { + self.battery.at_rate_ok().await + } + + async fn current(&mut self) -> Result { + self.battery.current().await + } + + async fn average_current(&mut self) -> Result { + self.battery.average_current().await + } + + async fn max_error(&mut self) -> Result { + self.battery.max_error().await + } + + async fn relative_state_of_charge(&mut self) -> Result { + self.battery.relative_state_of_charge().await + } + + async fn absolute_state_of_charge(&mut self) -> Result { + self.battery.absolute_state_of_charge().await + } + + async fn remaining_capacity(&mut self) -> Result { + self.battery.remaining_capacity().await + } + + async fn full_charge_capacity(&mut self) -> Result { + self.battery.full_charge_capacity().await + } + + async fn run_time_to_empty(&mut self) -> Result { + self.battery.run_time_to_empty().await + } + + async fn average_time_to_empty(&mut self) -> Result { + self.battery.average_time_to_empty().await + } + + async fn average_time_to_full(&mut self) -> Result { + self.battery.average_time_to_full().await + } + + async fn charging_current(&mut self) -> Result { + self.battery.charging_current().await + } + + async fn charging_voltage(&mut self) -> Result { + self.battery.charging_voltage().await + } + + async fn battery_status(&mut self) -> Result { + self.battery.battery_status().await + } + + async fn cycle_count(&mut self) -> Result { + self.battery.cycle_count().await + } + + async fn design_capacity(&mut self) -> Result { + self.battery.design_capacity().await + } + + async fn design_voltage(&mut self) -> Result { + self.battery.design_voltage().await + } + + async fn specification_info(&mut self) -> Result { + self.battery.specification_info().await + } + + async fn manufacture_date(&mut self) -> Result { + self.battery.manufacture_date().await + } + + async fn serial_number(&mut self) -> Result { + self.battery.serial_number().await + } + + async fn manufacturer_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.manufacturer_name(v).await + } + + async fn device_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_name(v).await + } + + async fn device_chemistry(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_chemistry(v).await + } +} + +impl Controller for MockBatteryController +{ + type ControllerError = MockBatteryError; + + async fn initialize(&mut self) -> Result<(), Self::ControllerError> { + Ok(()) + } + + async fn get_static_data(&mut self) -> Result { + let mut name = [0u8; 21]; + let mut device = [0u8; 21]; + let mut chem = [0u8; 5]; + + println!("MockBatteryController: Fetching static data"); + + self.battery.manufacturer_name(&mut name).await?; + self.battery.device_name(&mut device).await?; + self.battery.device_chemistry(&mut chem).await?; + + let capacity = match self.battery.design_capacity().await? { + CapacityModeValue::MilliAmpUnsigned(v) => v, + _ => 0, + }; + + let voltage = self.battery.design_voltage().await?; + + // This is a placeholder, replace with actual logic to determine chemistry ID + // For example, you might have a mapping of chemistry names to IDs + let chem_id = [0x01, 0x02]; // example + + // Serial number is a 16-bit value, split into 4 bytes + // where the first two bytes are zero + let raw = self.battery.serial_number().await?; + let serial = [0, 0, (raw >> 8) as u8, (raw & 0xFF) as u8]; + + Ok(StaticBatteryMsgs { + manufacturer_name: name, + device_name: device, + device_chemistry: chem, + design_capacity_mwh: capacity as u32, + design_voltage_mv: voltage, + device_chemistry_id: chem_id, + serial_num: serial, + }) + } + + + async fn get_dynamic_data(&mut self) -> Result { + println!("MockBatteryController: Fetching dynamic data"); + + // Pull values from SmartBattery trait + let full_capacity = match self.battery.full_charge_capacity().await? { + CapacityModeValue::MilliAmpUnsigned(val) => val as u32, + _ => 0, + }; + + let remaining_capacity = match self.battery.remaining_capacity().await? { + CapacityModeValue::MilliAmpUnsigned(val) => val as u32, + _ => 0, + }; + + let battery_status = { + let status = self.battery.battery_status().await?; + // Bit masking matches the SMS specification + let mut result: u16 = 0; + result |= (status.fully_discharged() as u16) << 0; + result |= (status.fully_charged() as u16) << 1; + result |= (status.discharging() as u16) << 2; + result |= (status.initialized() as u16) << 3; + result |= (status.remaining_time_alarm() as u16) << 4; + result |= (status.remaining_capacity_alarm() as u16) << 5; + result |= (status.terminate_discharge_alarm() as u16) << 7; + result |= (status.over_temp_alarm() as u16) << 8; + result |= (status.terminate_charge_alarm() as u16) << 10; + result |= (status.over_charged_alarm() as u16) << 11; + result |= (status.error_code() as u16) << 12; + result + }; + + let relative_soc_pct = self.battery.relative_state_of_charge().await? as u16; + let cycle_count = self.battery.cycle_count().await?; + let voltage_mv = self.battery.voltage().await?; + let max_error_pct = self.battery.max_error().await? as u16; + let charging_voltage_mv = 0; // no charger implemented yet + let charging_current_ma = 0; // no charger implemented yet + let battery_temp_dk = self.battery.temperature().await?; + let current_ma = self.battery.current().await?; + let average_current_ma = self.battery.average_current().await?; + + // For now, placeholder sustained/max power + let max_power_mw = 0; + let sus_power_mw = 0; + + Ok(DynamicBatteryMsgs { + max_power_mw, + sus_power_mw, + full_charge_capacity_mwh: full_capacity, + remaining_capacity_mwh: remaining_capacity, + relative_soc_pct, + cycle_count, + voltage_mv, + max_error_pct, + battery_status, + charging_voltage_mv, + charging_current_ma, + battery_temp_dk, + current_ma, + average_current_ma, + }) + } + + async fn get_device_event(&mut self) -> ControllerEvent { + loop { + Timer::after(Duration::from_secs(60)).await; + } + } + + async fn ping(&mut self) -> Result<(), Self::ControllerError> { + Ok(()) + } + + fn get_timeout(&self) -> Duration { + Duration::from_secs(10) + } + + fn set_timeout(&mut self, _duration: Duration) { + // Ignored for mock + } +} +``` + +Now we have a consistent and rational pattern to each of our controller models. + +As previously mentioned, note that these changes break the constructor calling in the previous example exercises, so if you are intent on keeping the previous exercises building, you will need to refactor those. +You would need to change any references to MockBatteryController in any of the existing code that uses the former version to be simply `MockBatteryController` and will need to update any calls to the constructor to pass the `MockBatteryDevice` instance instead of `MockBattery`. +There are likely other ramifications with regard to multiple borrows that still remain in the previous code that you will have to choose how to mitigate as well. + +### As long as we're updating controllers... +An oversight in the implementation of some of the `SmartBattery` traits of `MockBatteryController` fail to pass the buffer parameter down into the underlying implementation. Although this won't materially affect the build here, it should be remedied. Replace these methods in `battery_project/mock_battery/src/mock_battery_controller.rs` with the versions below: + +```rust + async fn manufacturer_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.manufacturer_name(buf).await + } + + async fn device_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_name(buf).await + } + + async fn device_chemistry(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_chemistry(buf).await + } +``` + + + + + + + + diff --git a/guide_book/src/how/ec/integration/5-structural_steps.md b/guide_book/src/how/ec/integration/5-structural_steps.md new file mode 100644 index 0000000..0361da8 --- /dev/null +++ b/guide_book/src/how/ec/integration/5-structural_steps.md @@ -0,0 +1,249 @@ +# The Structural Steps of our new Integration +Before we start implementing, it is worth setting the overview and expectations for how we will bring up this new integrated scaffolding. + +Like most applications, ours starts with the `main()` function. In deference to a later targeting to an embedded build, we will mark this as an `#[embassy_executor::main]`, which saves us the trouble of spinning up our own instance of embassy-executor in order to spawn our tasks. Nevertheless, the `main()` function's only job here is to spin up the `entry_task_interactive` start point (later, we'll have a separate, similar entry point for test mode). + +Put this content into your `main.rs`: + +```rust + +use embassy_executor::Spawner; + +mod config; +mod policy; +mod model; +mod state; +mod events; +mod entry; +mod setup_and_tap; +mod controller_core; + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + + spawner.spawn(entry::entry_task_interactive(spawner)).unwrap(); +} +``` + +note the `mod` lines here that bring in the configuration definitions we constructed previously, as well as our new consolidated local `events.rs`. This is similar to what we did using `lib.rs` in previous examples, but since this is a singular app and not a crate, per-se, we will use `main.rs` as the aggregation point for external files to include. + +You will see that in addition to the configuration files we have already created, we also make reference to the following: +- `entry` +- `setup_and_tap` +- `controller_core` + +You can go ahead and create these files in `src` (`entry.rs`, `setup_and_tap.rs`, `controller_core.rs`) and leave them empty for now, as we will be filling them out in the next few steps. + +First, let's explain what we have in mind for each of them: + +We will place our `entry_task_interactive` in a new file we create named `entry.rs`, which we will construct in a moment. This file will be responsible mostly for allocation of our components and the definition of our new comm "channels". + +Next in line for the startup of our scaffolding is contained in a file we will name `setup_and_tap.rs`. This file is responsible for initializing the components and the services. The `tap` part of its name comes from the nature of how we interface with the Battery service inherent in `embedded-services`. As we noted earlier, in previous exercises, we prepared for using this service, but never actually did use it, and therefore needed to do much of our own event wiring rather than adhere to the default event sequence ODP provides for us. To actually use it, we must give ownership of our BatteryController to the service, and use the callbacks into the trait methods invoked by messages to gain access to our wider integrated scope (hence `tap`ping into it). +Our _"wider integrated scope"_ is represented by `controller_core.rs` where we will implement the required traits necessary for a `Battery` Controller so that we can give it to the Battery service, while keeping all of our actual components held close as member properties. This allows us to treat the integration as a unified whole without breaking ownership rules. + +## The beginnings of entry.rs + +`entry.rs` defines the thin wrappers we put around our new comm Channel implementations (replacing `EspiService`), and it establishes the static residence for many of our top-level components. + +Let's start it out with this content: +```rust +use static_cell::StaticCell; + +use embassy_sync::channel::Channel; +use embassy_sync::once_lock::OnceLock; + +use ec_common::mutex::RawMutex; +use ec_common::espi_service::{ + EventChannel, MailboxDelegateError +}; +use ec_common::fuel_signal_ready::BatteryFuelReadySignal; +use ec_common::events::ThermalEvent; + +use battery_service::context::BatteryEvent; +use battery_service::device::{Device as BatteryDevice, DeviceId as BatteryServiceDeviceId}; + +use embedded_services::power::policy::charger::ChargerEvent; + +pub const BATTERY_DEV_NUM: u8 = 1; +pub const CHARGER_DEV_NUM: u8 = 2; +pub const SENSOR_DEV_NUM: u8 = 3; +pub const FAN_DEV_NUM: u8 = 4; + + +// ---------- Channels as thin wrappers ---------- +const CHANNEL_CAPACITY:usize = 16; + +pub struct BatteryChannelWrapper(pub Channel); +#[allow(unused)] +impl BatteryChannelWrapper { + pub async fn send(&self, e: BatteryEvent) { self.0.send(e).await } + pub async fn receive(&self) -> BatteryEvent { self.0.receive().await } +} +impl EventChannel for BatteryChannelWrapper { + type Event = BatteryEvent; + fn try_send(&self, e: BatteryEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct ChargerChannelWrapper(pub Channel); +impl EventChannel for ChargerChannelWrapper { + type Event = ChargerEvent; + fn try_send(&self, e: ChargerEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct ThermalChannelWrapper(pub Channel); +impl EventChannel for ThermalChannelWrapper { + type Event = ThermalEvent; + fn try_send(&self, e: ThermalEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct DisplayChannelWrapper(pub Channel); +#[allow(unused)] +impl DisplayChannelWrapper { + pub async fn send(&self, e: DisplayEvent) { self.0.send(e).await } + pub async fn receive(&self) -> DisplayEvent { self.0.receive().await } +} +impl EventChannel for DisplayChannelWrapper { + type Event = DisplayEvent; + fn try_send(&self, e: DisplayEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct InteractionChannelWrapper(pub Channel); +impl InteractionChannelWrapper { + pub async fn send(&self, e: InteractionEvent) { self.0.send(e).await } + pub async fn receive(&self) -> InteractionEvent { self.0.receive().await } +} +impl EventChannel for InteractionChannelWrapper { + type Event = InteractionEvent; + fn try_send(&self, e: InteractionEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} + +// ---------- Statics ---------- +// Keep StaticCell for things that truly need &'static mut (exclusive owner) +static BATTERY_FUEL: StaticCell = StaticCell::new(); + +// Channels + ready via OnceLock (immutable access pattern) +static BATTERY_FUEL_READY: OnceLock = OnceLock::new(); + +static BATTERY_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static CHARGER_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static THERMAL_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static DISPLAY_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static INTERACTION_EVENT_CHANNEL: OnceLock = OnceLock::new(); + +``` +We still want the BATTERY_FUEL_READY signal that we used in previous integrations. This tells us when the battery service is fully ready and we are safe to move ahead with other task activity. + +Even though we have chosen to unify the channel handling, we still want to create separate addressable channel wrappers that include their own unique Event types for sending. We define channels for each of our components, but also for the actions delegated to the Display and user input interaction. + +One may note that the `try_send` method of the channel wrapper code above does not handle errors particularly well. +It is assumed in this simple implementation that the channel is available and never fills up (capacity = 16). A more defensive strategy would check for back-pressure on the channel and throttle messaging appropriately. Keep this in mind when implementing real-world scenarios. + + +## Introducing the UI +At this point in the setup of the scaffolding we should introduce the elements that make up the User Interface portion of our simulation. +As we outlined at the beginning of this exercise, we are seeking to make this integration run as an interactive simulation, a non-interactive simulation, and an integration test as well. + +We will focus first on the simulation app aspects before considering our integration test implementation. + +To implement our UI, we introduce a `SystemObserver`, an intermediary between the simulation and the UI, including handling the rendering. + +Our rendering will assume two forms: We'll support a conventional "Logging" output that simply prints lines in sequence to the console as the values occur, because this is useful for analysis and debugging of behavior over time. But we will also support ANSI terminal cursor coding to support an "in-place" display that presents more of a dashboard view with changing values. This makes evaluation of the overall behavior and "feel" of our simulation and its behavior a little more approachable. + +Our simulation will also be interactive, allowing us to simulate increasing and decreasing load on the system, as one might experience during use of a typical laptop computer. + +So let's get to it. First, we'll define those values we wish to be displayed by the UI. + +Create the file `display_models.rs` and give it this content to start: + +```rust +#[derive(Clone, Debug)] +/// Static values that are displayed +pub struct StaticValues { + /// Battery manufacturer name + pub battery_mfr: String, + /// Battery model name + pub battery_name: String, + /// Battery chemistry type (e.g. LION) + pub battery_chem: String, + /// Battery serial number + pub battery_serial: String, + /// Battery designed mW capacity + pub battery_dsgn_cap_mwh: u32, + /// Battery designed mV capacity + pub battery_dsgn_voltage_mv: u16, +} +impl StaticValues { + pub fn new() -> Self { + Self { + battery_mfr: String::new(), + battery_name: String::new(), + battery_chem: String::new(), + battery_serial: String::new(), + battery_dsgn_cap_mwh: 0, + battery_dsgn_voltage_mv: 0, + } + } +} + +#[derive(Clone, Debug)] +/// Properties that are displayed by the renderer +pub struct DisplayValues { + /// Current running time of simulator (milliseconds) + pub sim_time_ms: f32, + /// Percent of State of Charge + pub soc_percent: f32, + /// battery/sensor temperature (Celsius) + pub temp_c: f32, + /// Fan Level (integer number 0-10) + pub fan_level: u8, + /// Fan level percentage + pub fan_percent: u8, + /// Fan running RPM + pub fan_rpm: u16, + + /// Current draw from system load (mA) + pub load_ma: u16, + /// Charger input (mA) + pub charger_ma: u16, + /// net difference to battery + pub net_batt_ma: i16, + + /// System draw in watts + pub draw_watts: f32, + /// System charge in watts + pub charge_watts: f32, + /// Net difference in watts + pub net_watts: f32 +} + +impl DisplayValues { + pub fn new() -> Self { + Self { + sim_time_ms: 0.0, + soc_percent: 0.0, + temp_c: 0.0, + fan_level: 0, + fan_percent: 0, + fan_rpm: 0, + + load_ma: 0, + charger_ma: 0, + net_batt_ma: 0, + draw_watts: 0.0, + charge_watts: 0.0, + net_watts: 0.0 + } + } +} +``` +This set of structures defines both the static and dynamic values of the system that will be tracked and displayed. + +These values pair up with the other configuration values we've already defined. + +Now let's move on with starting to build the scaffolding that supports all of this. diff --git a/guide_book/src/how/ec/integration/6-scaffold_start.md b/guide_book/src/how/ec/integration/6-scaffold_start.md new file mode 100644 index 0000000..4983831 --- /dev/null +++ b/guide_book/src/how/ec/integration/6-scaffold_start.md @@ -0,0 +1,71 @@ +# Scaffolding start-up +The `main()` function of our program immediately calls into the task `entry_task_interactive`, which is where the true entry to our integration app gets underway. + +We need to instantiate the various parts of our integration. This includes the parts that make up the scaffolding of the integration itself, such as the comm channels that carry component messages, and the parts responsible for display of data, and for user interaction. We did some of that in the previous step, in the first parts of `entry.rs`. + +The integration scaffolding of course also includes the integrated components themselves. The components will be managed together in a structure we will call `ComponentCore`. This core will be independent of the display and interaction mechanics, which will be handled primarily by a structure we will call `SystemObserver`. + +We will set about defining `ComponentCore` and `SystemObserver` shortly, but for now, we will concentrate on finishing out our basic scaffolding. + +## Shared items +We can group and share some of the common elements in a collection we will called `Shared`. This includes +the various comm `Channels` we have defined, and it will also hold the reference to our `SystemObserver` when we introduce that later. + +Add the following to `entry.rs`: + +```rust +// ---------- Shared handles for both modes ---------- +// Shared, Sync-clean. This can safely sit in a static OnceLock<&'static Shared>. +pub struct Shared { + // pub observer: &'static SystemObserver, + pub battery_channel: &'static BatteryChannelWrapper, + pub charger_channel: &'static ChargerChannelWrapper, + pub thermal_channel: &'static ThermalChannelWrapper, + pub display_channel: &'static DisplayChannelWrapper, + pub interaction_channel: &'static InteractionChannelWrapper, + pub battery_ready: &'static BatteryFuelReadySignal, + pub battery_fuel: &'static BatteryDevice, +} + +static SHARED_CELL: StaticCell = StaticCell::new(); +static SHARED: OnceLock<&'static Shared> = OnceLock::new(); + +fn init_shared() -> &'static Shared { + // Channels + ready + let battery_channel = BATTERY_EVENT_CHANNEL.get_or_init(|| BatteryChannelWrapper(Channel::new())); + let charger_channel = CHARGER_EVENT_CHANNEL.get_or_init(|| ChargerChannelWrapper(Channel::new())); + let thermal_channel = THERMAL_EVENT_CHANNEL.get_or_init(|| ThermalChannelWrapper(Channel::new())); + let display_channel = DISPLAY_EVENT_CHANNEL.get_or_init(|| DisplayChannelWrapper(Channel::new())); + let interaction_channel = INTERACTION_EVENT_CHANNEL.get_or_init(|| InteractionChannelWrapper(Channel::new())); + let battery_ready = BATTERY_FUEL_READY.get_or_init(|| BatteryFuelReadySignal::new()); + + let b =VirtualBatteryState::new_default(); + let v_nominal_mv = b.design_voltage_mv; + + // let observer = SYS_OBS.init(SystemObserver::new( + // Thresholds::new(), + // v_nominal_mv, + // display_channel + // )); + let battery_fuel = BATTERY_FUEL.init(BatteryDevice::new(BatteryServiceDeviceId(BATTERY_DEV_NUM))); + + SHARED.get_or_init(|| SHARED_CELL.init(Shared { + // observer, + battery_channel, + charger_channel, + thermal_channel, + display_channel, + interaction_channel, + battery_ready, + battery_fuel, + })) +} +``` + +Note the references to `observer` are commented out for now... we'll attach those in a later step. + +We'll continue the bootstrapping of our integration setup in the next step, where we will set up the components into our scaffolding. + + + + diff --git a/guide_book/src/how/ec/integration/7-setup_and_tap.md b/guide_book/src/how/ec/integration/7-setup_and_tap.md new file mode 100644 index 0000000..feb69c0 --- /dev/null +++ b/guide_book/src/how/ec/integration/7-setup_and_tap.md @@ -0,0 +1,205 @@ +# Setup and Tap +Before we can construct our `ControllerCore` structure, we need to have the allocations of the components ready. +We choose not to pass these around beyond constructing them into a single location, since we may run into borrow violations if we hand the references out too liberally, like we have seen in our previous integration attempts. + +This becomes even more complicated by the fact that when we commit our Battery Controller object to the battery service, we pass ownership to it -- and therefore lose access to our own construction. The solution here is to not give the battery service control of our Battery directly, but to give it a `BatteryAdapter` that looks like a battery, but instead simply forwards all of its actions to our `ControllerCore`. We call this "tapping" the service. In the `ControllerCore` we have access to not only our own battery, but also our charger and thermal components, so we can conduct our integration in a unified way. That said, we will still avoid tightly-coupled access between components as much as possible in favor of using messaging, because this pattern fosters better modularity. + +## In a view +The diagram below shows the ownership and message flow at a glance: + +```mermaid +flowchart LR + + %% --- UI --- + subgraph UI[UI] + direction TB + User[User] + Obs[SystemObserver] + Rend[DisplayRenderer] + end + + %% --- Channels --- + subgraph Channels[Channels] + direction TB + IChan[InteractionChannel] + DChan[DisplayChannel] + Bc[BatteryChannel] + Cc[ChargerChannel] + Tc[ThermalChannel] + end + + %% --- Service --- + subgraph Service[Service] + direction TB + W[Wrapper] --> A[BatteryAdapter] + end + + %% --- Core --- + subgraph Core[Core] + direction TB + CC[ControllerCore] + B[MockBatteryController] + C[MockChargerController] + S[MockSensorController] + F[MockFanController] + CC --> B & C & S & F + end + + %% --- Wiring --- + User --> IChan + IChan --> CC + + A --> CC + + CC --> Obs + Obs --> DChan + DChan --> Rend + + CC --> Bc + CC --> Cc + CC --> Tc +``` + +### The setup_and_tap code + +Create `setup_and_tap.rs` and give it this content to start: +```rust +use embassy_executor::Spawner; +use embassy_time::Duration; +use static_cell::StaticCell; +use embassy_sync::once_lock::OnceLock; +use ec_common::mutex::{Mutex, RawMutex}; + +use crate::entry::{Shared, BATTERY_DEV_NUM, CHARGER_DEV_NUM, SENSOR_DEV_NUM, FAN_DEV_NUM}; +use crate::controller_core::ControllerCore; + +use embedded_services::init; +use embedded_services::power::policy::register_device; +use embedded_services::power::policy::DeviceId; +use embedded_services::power::policy::device::Device as PolicyDevice; + +use mock_battery::mock_battery_device::MockBatteryDevice; +use mock_battery::mock_battery_controller::MockBatteryController; + +use mock_charger::mock_charger_device::MockChargerDevice; +use mock_charger::mock_charger_controller::MockChargerController; +use embedded_services::power::policy::charger::Device as ChargerDevice; // disambiguate from other device types +use embedded_services::power::policy::charger::ChargerId; + +use mock_thermal::mock_sensor_device::MockSensorDevice; +use mock_thermal::mock_fan_device::MockFanDevice; +use mock_thermal::mock_sensor_controller::MockSensorController; +use mock_thermal::mock_fan_controller::MockFanController; + +use battery_service::wrapper::Wrapper; + +use crate::battery_adapter::BatteryAdapter; + +// ---------- statics that must live for 'static tasks ---------- +static BATTERY_WRAPPER: StaticCell> = StaticCell::new(); + +static BATTERY_DEVICE: StaticCell = StaticCell::new(); +static BATTERY_POLICY_DEVICE: StaticCell = StaticCell::new(); + +static CHARGER_DEVICE: StaticCell = StaticCell::new(); +static CHARGER_POLICY_DEVICE: StaticCell = StaticCell::new(); +static CHARGER_SERVICE_DEVICE: OnceLock = OnceLock::new(); + + +static SENSOR_DEVICE: StaticCell = StaticCell::new(); +static SENSOR_POLICY_DEVICE: StaticCell = StaticCell::new(); +static FAN_DEVICE: StaticCell = StaticCell::new(); +static FAN_POLICY_DEVICE: StaticCell = StaticCell::new(); + +/// Initialize registration of all the integration components +#[embassy_executor::task] +pub async fn setup_and_tap_task(spawner: Spawner, shared: &'static Shared) { + println!("⚙️ Initializing embedded-services"); + init().await; + + println!("⚙️ Spawning battery service task"); + spawner.spawn(battery_service::task()).unwrap(); + + // ----------------- Device/controller construction ----------------- + let battery_dev = BATTERY_DEVICE.init(MockBatteryDevice::new(DeviceId(BATTERY_DEV_NUM))); + let battery_policy_dev = BATTERY_POLICY_DEVICE.init(PolicyDevice::new(DeviceId(BATTERY_DEV_NUM))); + + // Build the battery controller locally and MOVE it into the wrapper below. + // (No StaticCell needed for the controller since the wrapper will own it.) + let battery_controller = MockBatteryController::new(battery_dev); + + // Similar for others, although they are not moved into wrapper + let charger_dev = CHARGER_DEVICE.init(MockChargerDevice::new(DeviceId(CHARGER_DEV_NUM))); + let charger_policy_dev = CHARGER_POLICY_DEVICE.init(MockChargerDevice::new(DeviceId(CHARGER_DEV_NUM))); + let charger_controller = MockChargerController::new(charger_dev); + + + // Thermal (controllers own their devices) + let sensor_dev = SENSOR_DEVICE.init(MockSensorDevice::new(DeviceId(SENSOR_DEV_NUM))); + let sensor_policy_dev = SENSOR_POLICY_DEVICE.init(MockSensorDevice::new(DeviceId(SENSOR_DEV_NUM))); + let fan_dev = FAN_DEVICE.init(MockFanDevice::new(DeviceId(FAN_DEV_NUM))); + let fan_policy_dev = FAN_POLICY_DEVICE.init(MockFanDevice::new(DeviceId(FAN_DEV_NUM))); + let sensor_controller = MockSensorController::new(sensor_dev); + let fan_controller = MockFanController::new(fan_dev); + + let charger_service_device: &'static ChargerDevice = CHARGER_SERVICE_DEVICE.get_or_init(|| ChargerDevice::new(ChargerId(CHARGER_DEV_NUM))); + + // Then use these to create our ControllerTap handler, which isolates ownership of all but the battery, which is + // owned by the Wrapper. We can access the other "real" controllers upon battery message receipts by the Tap. + // We must still stick to message passing to communicate between components to preserve modularity. + let controller_core = ControllerCore::new( + battery_controller, charger_controller, sensor_controller, fan_controller, + charger_service_device, + shared.battery_channel,shared.charger_channel,shared.thermal_channel,shared.interaction_channel, + shared.observer, + ); + + static TAP_CELL: StaticCell> = StaticCell::new(); + let core_mutex: &'static Mutex = TAP_CELL.init(Mutex::new(controller_core)); + let battery_adapter = BatteryAdapter::new(core_mutex); + + + // ----------------- Battery wrapper ----------------- + println!("⚙️ Spawning battery wrapper task"); + let wrapper = BATTERY_WRAPPER.init(Wrapper::new( + shared.battery_fuel, // &'static BatteryDevice, provided by Instances + battery_adapter // move ownership into the wrapper + )); + spawner.spawn(battery_wrapper_task(wrapper)).unwrap(); + + // Registrations + println!("🧩 Registering battery device..."); + register_device(battery_policy_dev).await.unwrap(); + + println!("🧩 Registering charger device..."); + register_device(charger_policy_dev).await.unwrap(); + + println!("🧩 Registering sensor device..."); + register_device(sensor_policy_dev).await.unwrap(); + + println!("🧩 Registering fan device..."); + register_device(fan_policy_dev).await.unwrap(); + + // ----------------- Fuel gauge / ready ----------------- + println!("🔌 Initializing battery fuel gauge service..."); + battery_service::register_fuel_gauge(&shared.battery_fuel).await.unwrap(); + + spawner.spawn(battery_start_task()).unwrap(); + + // signal that the battery fuel service is ready + shared.battery_ready.signal(); + + println!("Setup and Tap calling ControllerCore::start..."); + ControllerCore::start(core_mutex, spawner); + +} +``` +This starts out by allocating and creating the components that we will need, starting with the aforementioned `BatteryAdapter`, which we will implement in a moment, and creating the `BatteryWrapper` with this in mind. + +It then creates the battery, charger, sensor, and fan components. You may notice that in doing so we create both a DEVICE and a POLICY_DEVICE for each. Both of these Device type wrappers are identical per component. One is used to create the controller, and one is used to register the device with the service. Since these are tied by Id designation, they are equivalent, and since we can't pass a single instance twice without incurring a borrow violation, we use this technique. + +This brings us to the construction of the `ControllerCore`. Here, we give it all of the components, plus the comm channels that were shared from our earlier allocations in `entry.rs`. We also see here we are passing references to a new channel `integration_channel`, and the `SystemObserver`, neither of which we have created yet. + +Once we get our `ControllerCore` instance created, we wrap it into a mutex that we stash into a `StaticCell` so that we have portable access to this structure. + +The remainder of the `setup_and_tap_task` proceeds with registration and then spawning the execution tasks. diff --git a/guide_book/src/how/ec/integration/8-battery_adapter.md b/guide_book/src/how/ec/integration/8-battery_adapter.md new file mode 100644 index 0000000..2599b61 --- /dev/null +++ b/guide_book/src/how/ec/integration/8-battery_adapter.md @@ -0,0 +1,306 @@ +# Battery Adapter + +The battery service expects to be handed a type that implements the `SmartBattery` trait, as well as the `Controller` trait defined in the `battery_service::controller` module. We can create a simple adapter type that holds a reference to our `ControllerCore` mutex, and then forwards the trait method calls into the core controller code. + +```rust +use crate::controller_core::ControllerCore; +#[allow(unused_imports)] +use ec_common::mutex::{RawMutex, Mutex}; +use core::sync::atomic::{AtomicU64, Ordering}; + +#[allow(unused_imports)] + use battery_service::controller::{Controller, ControllerEvent}; +use battery_service::device::{DynamicBatteryMsgs, StaticBatteryMsgs}; +use embassy_time::Duration; +use mock_battery::mock_battery::MockBatteryError; +#[allow(unused_imports)] + use embedded_batteries_async::smart_battery::{ + SmartBattery, + ManufactureDate, SpecificationInfoFields, CapacityModeValue, CapacityModeSignedValue, + BatteryModeFields, BatteryStatusFields, + DeciKelvin, MilliVolts + }; + +const DEFAULT_TIMEOUT_MS: u64 = 1000; + +#[allow(unused)] + pub struct BatteryAdapter { + core_mutex: &'static Mutex, + timeout_ms: AtomicU64 // cached timeout to work around sync/async mismatch + } + + impl BatteryAdapter { +#[allow(unused)] + pub fn new(core_mutex: &'static Mutex) -> Self { + Self { + core_mutex, + timeout_ms: AtomicU64::new(DEFAULT_TIMEOUT_MS) + } + } + + #[inline] + fn dur_to_ms(d: Duration) -> u64 { + // Use the unit that’s most convenient for you; ms is usually fine. + d.as_millis() as u64 + } + + #[inline] + fn ms_to_dur(ms: u64) -> Duration { + Duration::from_millis(ms as u64) + } + + // called on Controller methods to shadow timeout value we can forward in a synchronous trait method + fn sync_timeout_cache(&self, core: &mut ControllerCore) { + use core::sync::atomic::Ordering; + let cached = self.timeout_ms.load(Ordering::Relaxed); + let current = Self::dur_to_ms(core.get_timeout()); + if current != cached { + core.set_timeout(Self::ms_to_dur(cached)); + } + } + + } + +impl embedded_batteries_async::smart_battery::ErrorType for BatteryAdapter +{ + type Error = MockBatteryError; +} + + impl SmartBattery for BatteryAdapter { + async fn temperature(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.temperature().await + } + + async fn voltage(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.voltage().await + } + + async fn remaining_capacity_alarm(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.remaining_capacity_alarm().await + } + + async fn set_remaining_capacity_alarm(&mut self, v: CapacityModeValue) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_remaining_capacity_alarm(v).await + } + + async fn remaining_time_alarm(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.remaining_time_alarm().await + } + + async fn set_remaining_time_alarm(&mut self, v: u16) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_remaining_time_alarm(v).await + } + + async fn battery_mode(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.battery_mode().await + } + + async fn set_battery_mode(&mut self, v: BatteryModeFields) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_battery_mode(v).await + } + + async fn at_rate(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate().await + } + + async fn set_at_rate(&mut self, _: CapacityModeSignedValue) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_at_rate(CapacityModeSignedValue::MilliAmpSigned(0)).await + } + + async fn at_rate_time_to_full(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate_time_to_full().await + } + + async fn at_rate_time_to_empty(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate_time_to_empty().await + } + + async fn at_rate_ok(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate_ok().await + } + + async fn current(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.current().await + } + + async fn average_current(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.average_current().await + } + + async fn max_error(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.max_error().await + } + + async fn relative_state_of_charge(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.relative_state_of_charge().await + } + + async fn absolute_state_of_charge(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.absolute_state_of_charge().await + } + + async fn remaining_capacity(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.remaining_capacity().await + } + + async fn full_charge_capacity(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.full_charge_capacity().await + } + + async fn run_time_to_empty(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.run_time_to_empty().await + } + + async fn average_time_to_empty(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.average_time_to_empty().await + } + + async fn average_time_to_full(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.average_time_to_full().await + } + + async fn charging_current(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.charging_current().await + } + + async fn charging_voltage(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.charging_voltage().await + } + + async fn battery_status(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.battery_status().await + } + + async fn cycle_count(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.cycle_count().await + } + + async fn design_capacity(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.design_capacity().await + } + + async fn design_voltage(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.design_voltage().await + } + + async fn specification_info(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.specification_info().await + } + + async fn manufacture_date(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.manufacture_date().await + } + + async fn serial_number(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.serial_number().await + } + + async fn manufacturer_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.manufacturer_name(v).await + } + + async fn device_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.device_name(v).await + } + + async fn device_chemistry(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.device_chemistry(v).await + } + } + +impl Controller for BatteryAdapter { + + type ControllerError = MockBatteryError; + + async fn initialize(&mut self) -> Result<(), Self::ControllerError> { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.initialize().await + } + + + async fn get_static_data(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.get_static_data().await + } + + async fn get_dynamic_data(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.get_dynamic_data().await + } + + async fn get_device_event(&mut self) -> ControllerEvent { + core::future::pending().await + } + + async fn ping(&mut self) -> Result<(), Self::ControllerError> { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.ping().await + } + + fn get_timeout(&self) -> Duration { + // Fast path: if we can grab the mutex without waiting, read the real value. + if let Ok(guard) = self.core_mutex.try_lock() { + let d = guard.get_timeout(); // assumed non-async on core + self.timeout_ms.store(Self::dur_to_ms(d), Ordering::Relaxed); + d + } else { + // Fallback to cached value if the mutex is busy. + Self::ms_to_dur(self.timeout_ms.load(Ordering::Relaxed)) + } + } + + fn set_timeout(&mut self, duration: Duration) { + // Always update our cache immediately. + self.timeout_ms.store(Self::dur_to_ms(duration), Ordering::Relaxed); + + // Try to apply to the real controller right away if the mutex is free. + // if the mutex is busy, we'll simply use the previous cache next time. + if let Ok(mut guard) = self.core_mutex.try_lock() { + guard.set_timeout(duration); // assumed non-async on core + } + + + } +} +``` +As noted, the `BatteryAdapter` is nothing more than a forwarding mechanism to direct the trait methods called by the battery service into our code base. We pass it the reference to our `core_mutex` which is then used to call the battery controller traits implemented there, in our `ControllerCore` code. + diff --git a/guide_book/src/how/ec/integration/9-system_observer.md b/guide_book/src/how/ec/integration/9-system_observer.md new file mode 100644 index 0000000..ccdea0d --- /dev/null +++ b/guide_book/src/how/ec/integration/9-system_observer.md @@ -0,0 +1,308 @@ + +# The SystemObserver + +Before we can construct our `ControllerCore`, we still need a `SystemObserver` and and `InteractionChannelWrapper` to be defined. + +The `SystemObserver` is the conduit to display output and communicates with a `DisplayRenderer` used to portray output in various ways. The renderer itself is message-driven, as are user interaction events, so we will start by going back into `entry.rs` and adding both the `DisplayChannelWrapper` and `InteractionChannelWrapper` beneath the other "Channel Wrapper" definitions for Battery, Charger, and Thermal communication. +```rust +pub struct DisplayChannelWrapper(pub Channel); +#[allow(unused)] +impl DisplayChannelWrapper { + pub async fn send(&self, e: DisplayEvent) { self.0.send(e).await } + pub async fn receive(&self) -> DisplayEvent { self.0.receive().await } +} +impl EventChannel for DisplayChannelWrapper { + type Event = DisplayEvent; + fn try_send(&self, e: DisplayEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct InteractionChannelWrapper(pub Channel); +impl InteractionChannelWrapper { + pub async fn send(&self, e: InteractionEvent) { self.0.send(e).await } + pub async fn receive(&self) -> InteractionEvent { self.0.receive().await } +} +impl EventChannel for InteractionChannelWrapper { + type Event = InteractionEvent; + fn try_send(&self, e: InteractionEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +``` + +Now, let's create `system_observer.rs` and give it this content to start: +```rust +use crate::events::{DisplayEvent}; +use crate::display_models::{DisplayValues, StaticValues, InteractionValues, Thresholds}; +use crate::entry::DisplayChannelWrapper; +use embassy_time::{Instant, Duration}; +use ec_common::mutex::{Mutex, RawMutex}; + +struct ObserverState { + sv: StaticValues, // static values + dv: DisplayValues, // current working frame + last_sent: DisplayValues, // last emitted frame + last_emit_at: Instant, + first_emit: bool, + interaction: InteractionValues, + last_speed_number: u8 +} + +pub struct SystemObserver { + state: Mutex, + thresholds: Thresholds, + v_nominal_mv: u16, + min_emit_interval: Duration, + display_tx: &'static DisplayChannelWrapper, +} +impl SystemObserver { + pub fn new(thresholds: Thresholds, v_nominal_mv: u16, display_tx: &'static DisplayChannelWrapper) -> Self { + let now = Instant::now(); + Self { + state: Mutex::new(ObserverState { + sv: StaticValues::new(), + dv: DisplayValues::new(), // default starting values + last_sent: DisplayValues::new(), // default baseline + last_emit_at: now, + first_emit: true, + interaction: InteractionValues::default(), + last_speed_number: 0 + }), + thresholds, + v_nominal_mv, + min_emit_interval: Duration::from_millis(100), + display_tx, + } + } + + pub async fn increase_load(&self) { + let mut guard = self.state.lock().await; + guard.interaction.increase_load(); + } + pub async fn decrease_load(&self) { + let mut guard = self.state.lock().await; + guard.interaction.decrease_load(); + } + pub async fn set_speed_number(&self, speed_num: u8) { + let mut guard = self.state.lock().await; + guard.interaction.set_speed_number(speed_num); + } + pub async fn interaction_snapshot(&self) -> InteractionValues { + let guard = self.state.lock().await; + guard.interaction + } + + pub async fn toggle_mode(&self) { + let mut guard = self.state.lock().await; + guard.last_emit_at = Instant::now(); + guard.first_emit = true; + self.display_tx.send(DisplayEvent::ToggleMode).await; + } + pub async fn quit(&self) { + self.display_tx.send(DisplayEvent::Quit).await; + } + + pub async fn set_static(&self, new_sv: StaticValues) { + let mut guard = self.state.lock().await; + guard.sv = new_sv; + self.display_tx.send(DisplayEvent::Static(guard.sv.clone())).await; + } + + /// Full-frame update from ControllerCore + pub async fn update(&self, mut new_dv: DisplayValues, ia: InteractionValues) { + // Derive any secondary numbers in one place (keeps UI/logs consistent). + derive_power(&mut new_dv, self.v_nominal_mv); + + let mut guard = self.state.lock().await; + guard.dv = new_dv; + + let now = Instant::now(); + let should_emit = + guard.first_emit || + (ia.sim_speed_number != guard.last_speed_number) || + (now - guard.last_emit_at >= self.min_emit_interval && + diff_exceeds(&guard.dv, &guard.last_sent, &self.thresholds)); + + if should_emit { + self.display_tx.send(DisplayEvent::Update(guard.dv.clone(), ia)).await; + guard.last_sent = guard.dv.clone(); + guard.last_emit_at = now; + guard.first_emit = false; + guard.last_speed_number = ia.sim_speed_number; + } + } +} + +// ------- helpers (keep them shared so renderers never recompute differently) ------- +fn derive_power(dv: &mut DisplayValues, v_nominal_mv: u16) { + let draw_w = (dv.load_ma as i32 * v_nominal_mv as i32) as f32 / 1_000_000.0; + let charge_w = (dv.charger_ma as i32 * v_nominal_mv as i32) as f32 / 1_000_000.0; + dv.draw_watts = ((draw_w * 10.0).round()) / 10.0; + dv.charge_watts = ((charge_w * 10.0).round()) / 10.0; + dv.net_watts = (( (dv.charge_watts - dv.draw_watts) * 10.0).round()) / 10.0; + dv.net_batt_ma = dv.charger_ma as i16 - dv.load_ma as i16; +} + +fn diff_exceeds(cur: &DisplayValues, prev: &DisplayValues, th: &Thresholds) -> bool { + (cur.draw_watts - prev.draw_watts).abs() >= th.load_w_delta || + (cur.soc_percent - prev.soc_percent).abs() >= th.soc_pct_delta || + (cur.temp_c - prev.temp_c).abs() >= th.temp_c_delta || + (th.on_fan_change && cur.fan_level != prev.fan_level) +} +``` + +and to fill this out, we need to add to our `display_models.rs` file to define values used for Interaction and to define threshold ranges for the display. + +Add these definitions to `display__models.rs`: +```rust +#[allow(unused)] +#[derive(Clone, Copy)] +/// thresholds of change to warrant a display update +pub struct Thresholds { + /// minimum load change to report + /// e.g., 0.2 W + pub load_w_delta: f32, + /// minimum soc change to report + /// e.g., 0.5 % + pub soc_pct_delta: f32, + /// minimum temperature change to report + /// e.g., 0.2 °C + pub temp_c_delta: f32, + /// report if fan changes. + /// `true`` to update display if fan state changes + pub on_fan_change: bool, + + /// maximum wattage we can draw from system + pub max_load: f32, + /// warning we are getting hot + pub warning_temp: f32, + /// we are too hot + pub danger_temp: f32, + /// soc % is getting low + pub warning_charge: f32, + /// soc % is too low.. power fail imminent + pub danger_charge: f32, +} +impl Thresholds { + pub fn new() -> Self { + Self { + load_w_delta: 0.5, + soc_pct_delta: 0.1, + temp_c_delta: 0.5, + on_fan_change: true, + + max_load: 100.0, // 100W peak draw + warning_temp: 28.0, // 28 deg C (82.4 F) + danger_temp: 34.0, // 34 deg C (93.2 F) + warning_charge: 20.0, // <20% remaining + danger_charge: 8.0, // <8% remaining + } + } +} +#[derive(Debug, Clone, Copy)] +pub struct InteractionValues { + pub system_load: u16, + pub sim_speed_number: u8, + pub sim_speed_multiplier: f32 +} +const LOAD_INCREMENT: u16 = 100; // mA +const LOAD_MIN: u16 = 0; +const LOAD_MAX: u16 = 5000; + +const SPEED_SETTING : [u8; 5] = [1, 10, 25, 50, 100]; + +impl InteractionValues { + pub fn increase_load(&mut self) { + self.system_load = clamp_load(self.system_load.saturating_add(LOAD_INCREMENT)); + } + pub fn decrease_load(&mut self) { + self.system_load = clamp_load(self.system_load.saturating_sub(LOAD_INCREMENT)); + } + pub fn set_speed_number(&mut self, mut num:u8) { + if num < 1 { num = 1;} + if num > 5 { num = 5;} + self.sim_speed_number = num; + let idx:usize = num as usize -1; + self.sim_speed_multiplier = SPEED_SETTING[idx] as f32; + } + pub fn get_speed_number_and_multiplier(&self) -> (u8, f32) { + (self.sim_speed_number, self.sim_speed_multiplier) + } +} + +impl Default for InteractionValues { + fn default() -> Self { + Self { + system_load: 1200, + sim_speed_number: 3, + sim_speed_multiplier: 25.0 + } + } +} + +//-- helper functions +#[inline] +fn clamp_load(v: u16) -> u16 { + v.clamp(LOAD_MIN, LOAD_MAX) +} + +/// Power/units helpers for consistent display & logs. +/// +/// Conventions: +/// - Currents are mA (signed where net flow can be negative). +/// - Voltages are mV. +/// - Watts are f32, rounded for display to 0.1 W. +/// - Positive current into the system is "charger input"; positive load is "system draw". +/// - Net battery current = charger_ma - load_ma (mA). +/// - Net watts = charge_watts - draw_watts (W). +#[inline] +pub fn mw_from_ma_mv(ma: i32, mv: u16) -> i32 { + // exact integer math in mW to avoid float jitter for logs + (ma as i64 * mv as i64 / 1000) as i32 +} + +#[inline] +pub fn w_from_ma_mv(ma: i32, mv: u16) -> f32 { + // convenience for UI (single rounding site) + mw_from_ma_mv(ma, mv) as f32 / 1000.0 +} + +#[inline] +pub fn round_w_01(w: f32) -> f32 { + (w * 10.0).round() / 10.0 +} +``` + +and these definitions to `events.rs`: +```rust +use crate::display_models::{StaticValues, DisplayValues, InteractionValues}; + + +#[allow(unused)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RenderMode { + InPlace, // ANSI Terminal application + Log // line-based console output +} + +#[allow(unused)] +#[derive(Debug)] +pub enum DisplayEvent { + Update(DisplayValues, InteractionValues), // observer pushes new values to renderer + Static(StaticValues), // observer pushes new static values to renderer + ToggleMode, // switch between Log and InPlace RenderMode (forwarded from interactin) + Quit, // exit simulation (forwarded from interaction) +} + +#[allow(unused)] +#[derive(Debug)] +pub enum InteractionEvent { + LoadUp, // increase system load + LoadDown, // decrease system load + TimeSpeed(u8), // set time multiplier via speed number + ToggleMode, // switch between Log and InPlace RenderMode (forward to Display) + Quit, // exit simulation (forward to Display) +} +``` + +For now, we only need to provide `SystemObserver` and related structures as dependencies to the system so that we can construct a minimal standup for our first tests. We'll outfit it with the Display and Interaction features later. diff --git a/guide_book/src/how/ec/integration/integration.md b/guide_book/src/how/ec/integration/integration.md deleted file mode 100644 index d57595a..0000000 --- a/guide_book/src/how/ec/integration/integration.md +++ /dev/null @@ -1,95 +0,0 @@ -# Integration - -Before we turn our attention to making an embedded build to a hardware target, we want to make sure that we have a working integration of the -components in a virtual environment. This will allow us to test the interactions between the components and ensure that they work together as expected before we move on to the embedded build. - -In this section, we will cover the integration of all of our example components working together. -This integration will be similar to the previous examples, but with some additional complexity due to the interaction between the components. We will also explore how to test the integration of these components and ensure that they work together as expected. - -## A simulation -We will build this integration as both an integration test and as an executable app that runs the simulation of the components in action. This simulator will allows us to increase/decrease the load, mimicking the behavior of a real system, and we can then observe how the components interact with each other to keep the battery charged and the system cool over differing operating conditions. - -## Starting with comms - -We will start by building the communication layer that will allow the components to interact with each other. This will involve setting up the message passing system that will allow the components to send and receive messages, as well as setting up the service registry that will allow the components to discover each other. - -We've done much of this before. If you will recall the Battery and Charger integration tests, we extended the `EspiService` structure from supporting a single BatteryChannel to supporting a ChargerChannel as well. Now we will extend it further to support a ThermalChannel. - -### Setting up the integration project -We will create a new project space for this integration, rather than trying to shoehorn it into the existing battery or charger projects. This will allow us to keep the integration code separate from the component code, making it easier to manage and test. - -Create a new project directory in the `ec_examples` directory named `integration_project`. Give it a `Cargo.toml` file with the following content: - -```toml -# Battery-Charger Subsystem -[package] -name = "integration_project" -version = "0.1.0" -edition = "2024" -resolver = "2" -description = "System-level integration sim wiring Battery, Charger, and Thermal" - - -# We'll declare both a lib (for tests) and a bin (for the simulator) -[lib] -name = "integration_project" -path = "src/lib.rs" - -[[bin]] -name = "integration_sim" -path = "src/main.rs" - - -[dependencies] -embedded-batteries-async = { workspace = true } -embassy-executor = { workspace = true } -embassy-time = { workspace = true } -embassy-sync = { workspace = true } -embassy-futures = { workspace = true } -embassy-time-driver = { workspace = true } -embassy-time-queue-utils = { workspace = true } - -embedded-services = { workspace = true } -battery-service = { workspace = true } - -ec_common = { path = "../ec_common"} -mock_battery = { path = "../battery_project/mock_battery", default-features = false} -mock_charger = { path = "../charger_project/mock_charger", default-features = false} -mock_thermal = { path = "../thermal_project/mock_thermal", default-features = false} - -# Logging for the simulator -log = { version = "0.4", optional = true } -env_logger = { version = "0.11", optional = true } - -static_cell = "2.1" -futures = "0.3" -heapless = "0.8" - -[features] -default = ["std", "thread-mode"] -std = [] -thread-mode = [ - "mock_battery/thread-mode", - "mock_charger/thread-mode", - "mock_thermal/thread-mode" -] -noop-mode = [ - "mock_battery/noop-mode", - "mock_charger/noop-mode", - "mock_thermal/noop-mode" -] -``` - -Next, edit the `ec_examples/Cargo.toml` at the top level to add `integration_project` as a workspace member: - -```toml - members = [ - "battery_project/mock_battery", - "charger_project/mock_charger", - "thermal_project/mock_thermal", - "battery_charger_subsystem", - "integration_project", - "ec_common" -] -``` - diff --git a/guide_book/src/how/ec/integration/integration_tests.md b/guide_book/src/how/ec/integration/integration_tests.md deleted file mode 100644 index 206b406..0000000 --- a/guide_book/src/how/ec/integration/integration_tests.md +++ /dev/null @@ -1,3 +0,0 @@ -# Integration Tests -_TODO: The tests of the full integration_ - diff --git a/guide_book/src/how/ec/integration/media/integration-sim.png b/guide_book/src/how/ec/integration/media/integration-sim.png new file mode 100644 index 0000000000000000000000000000000000000000..1701f0cd8ea4a93ebea7c0b00d2c5d50382ea846 GIT binary patch literal 26264 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 Date: Tue, 19 Aug 2025 11:02:48 -0700 Subject: [PATCH 3/6] Merged --- guide_book/src/how/ec/integration/integration.md | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 guide_book/src/how/ec/integration/integration.md diff --git a/guide_book/src/how/ec/integration/integration.md b/guide_book/src/how/ec/integration/integration.md deleted file mode 100644 index cab4faf..0000000 --- a/guide_book/src/how/ec/integration/integration.md +++ /dev/null @@ -1,10 +0,0 @@ -# Integration - -Before we turn our attention to making an embedded build to a hardware target, we want to make sure that we have a working integration of the -components in a virtual environment. This will allow us to test the interactions between the components and ensure that they work together as expected before we move on to the embedded build. - -In this section, we will cover the integration of all of our example components working together. -This integration will be similar to the previous examples, but with some additional complexity due to the interaction between the components. We will also explore how to test the integration of these components and ensure that they work together as expected. - -_TODO_ - From c4efa2f0c1007f9eceb7bed7875d5801119ae498 Mon Sep 17 00:00:00 2001 From: Steven Ohmert Date: Sat, 20 Sep 2025 15:03:22 -0700 Subject: [PATCH 4/6] Completion of Integration section --- guide_book/src/SUMMARY.md | 31 +- .../src/how/ec/integration/1-integration.md | 201 ++++++ .../how/ec/integration/10-controller_core.md | 413 +++++++++++ .../src/how/ec/integration/11-first_tests.md | 195 +++++ .../ec/integration/12-tasks_and_listeners.md | 261 +++++++ .../ec/integration/13-integration_logic.md | 74 ++ .../ec/integration/14-display_rendering.md | 498 +++++++++++++ .../src/how/ec/integration/15-interaction.md | 139 ++++ .../how/ec/integration/16-in_place_render.md | 288 ++++++++ .../how/ec/integration/17-integration_test.md | 181 +++++ .../18-integration_test_structure.md | 549 ++++++++++++++ .../how/ec/integration/19-meaningful_tests.md | 253 +++++++ .../src/how/ec/integration/2-move_events.md | 675 ++++++++++++++++++ .../ec/integration/20-charger_attachment.md | 67 ++ .../how/ec/integration/21-affecting_change.md | 252 +++++++ .../how/ec/integration/22-summary_thoughts.md | 31 + .../src/how/ec/integration/3-better_alloc.md | 27 + .../how/ec/integration/4-update_controller.md | 450 ++++++++++++ .../how/ec/integration/5-structural_steps.md | 249 +++++++ .../how/ec/integration/6-scaffold_start.md | 71 ++ .../src/how/ec/integration/7-setup_and_tap.md | 205 ++++++ .../how/ec/integration/8-battery_adapter.md | 306 ++++++++ .../how/ec/integration/9-system_observer.md | 308 ++++++++ .../how/ec/integration/integration_tests.md | 3 - .../ec/integration/media/integration-sim.png | Bin 0 -> 26264 bytes 25 files changed, 5719 insertions(+), 8 deletions(-) create mode 100644 guide_book/src/how/ec/integration/1-integration.md create mode 100644 guide_book/src/how/ec/integration/10-controller_core.md create mode 100644 guide_book/src/how/ec/integration/11-first_tests.md create mode 100644 guide_book/src/how/ec/integration/12-tasks_and_listeners.md create mode 100644 guide_book/src/how/ec/integration/13-integration_logic.md create mode 100644 guide_book/src/how/ec/integration/14-display_rendering.md create mode 100644 guide_book/src/how/ec/integration/15-interaction.md create mode 100644 guide_book/src/how/ec/integration/16-in_place_render.md create mode 100644 guide_book/src/how/ec/integration/17-integration_test.md create mode 100644 guide_book/src/how/ec/integration/18-integration_test_structure.md create mode 100644 guide_book/src/how/ec/integration/19-meaningful_tests.md create mode 100644 guide_book/src/how/ec/integration/2-move_events.md create mode 100644 guide_book/src/how/ec/integration/20-charger_attachment.md create mode 100644 guide_book/src/how/ec/integration/21-affecting_change.md create mode 100644 guide_book/src/how/ec/integration/22-summary_thoughts.md create mode 100644 guide_book/src/how/ec/integration/3-better_alloc.md create mode 100644 guide_book/src/how/ec/integration/4-update_controller.md create mode 100644 guide_book/src/how/ec/integration/5-structural_steps.md create mode 100644 guide_book/src/how/ec/integration/6-scaffold_start.md create mode 100644 guide_book/src/how/ec/integration/7-setup_and_tap.md create mode 100644 guide_book/src/how/ec/integration/8-battery_adapter.md create mode 100644 guide_book/src/how/ec/integration/9-system_observer.md delete mode 100644 guide_book/src/how/ec/integration/integration_tests.md create mode 100644 guide_book/src/how/ec/integration/media/integration-sim.png diff --git a/guide_book/src/SUMMARY.md b/guide_book/src/SUMMARY.md index a8c43f0..3d42542 100644 --- a/guide_book/src/SUMMARY.md +++ b/guide_book/src/SUMMARY.md @@ -92,11 +92,32 @@ - [Standard Build](./how/ec/thermal/5-standard.md) - [Thermal Project](./how/ec/thermal/6-project.md) - [Thermal Traits](./how/ec/thermal/7-traits.md) - - [Device and Controller](./how/ec/thermal/8-device_and_controller.md) - - [Behavior](./how/ec/thermal/9-behavior.md) - - [Unit Tests](./how/ec/thermal/10-tests.md) - - [Integration](./how/ec/integration/integration.md) - -[Integration Tests](./how/ec/integration/integration_tests.md) + - [Thermal Values](./how/ec/thermal/8-values.md) + - [Service Prep](./how/ec/thermal/9-service_prep.md) + - [Thermal Service](./how/ec/thermal/10-service_registry.md) + - [Tests](./how/ec/thermal/11-tests.md) + - [Integration](./how/ec/integration/1-integration.md) + - [Move Events](./how/ec/integration/2-move_events.md) + - [Better Alloc](./how/ec/integration/3-better_alloc.md) + - [Update Controller](./how/ec/integration/4-update_controller.md) + - [Structure Steps](./how/ec/integration/5-structural_steps.md) + - [Scaffold Start](./how/ec/integration/6-scaffold_start.md) + - [Setup and Tap](./how/ec/integration/7-setup_and_tap.md) + - [Battery Adapter](./how/ec/integration/8-battery_adapter.md) + - [System Observer](./how/ec/integration/9-system_observer.md) + - [Controller Core](./how/ec/integration/10-controller_core.md) + - [First Tests](./how/ec/integration/11-first_tests.md) + - [Tasks and Listeners](./how/ec/integration/12-tasks_and_listeners.md) + - [Integration Logic](./how/ec/integration/13-integration_logic.md) + - [Display Rendering](./how/ec/integration/14-display_rendering.md) + - [Interaction](./how/ec/integration/15-interaction.md) + - [In Place Rendering](./how/ec/integration/16-in_place_render.md) + - [Integration Test](./how/ec/integration/17-integration_test.md) + - [Test Structure](./how/ec/integration/18-integration_test_structure.md) + - [Meaningful Tests](./how/ec/integration/19-meaningful_tests.md) + - [Charger Attach](./how/ec/integration/20-charger_attachment.md) + - [Affecting Change](./how/ec/integration/21-affecting_change.md) + - [Summary Thoughts](./how/ec/integration/22-summary_thoughts.md) - [Embedded Targeting](./how/ec/embedded_target/embedded_targeting.md) - [Project Board](./how/ec/embedded_target/project_board.md) - [Dependencies](./how/ec/embedded_target/embedded_dependencies.md) diff --git a/guide_book/src/how/ec/integration/1-integration.md b/guide_book/src/how/ec/integration/1-integration.md new file mode 100644 index 0000000..7521d9b --- /dev/null +++ b/guide_book/src/how/ec/integration/1-integration.md @@ -0,0 +1,201 @@ +# Integration + +Before we turn our attention to making an embedded build to a hardware target, we want to make sure that we have a working integration of the +components in a virtual environment. This will allow us to test the interactions between the components and ensure that they work together as expected ahead of moving onto the embedded build. + +In this section, we will cover the integration of all of our example components working together. +This integration will be similar to the previous examples, but with some additional complexity due to the interaction between the components. We will also explore how to test the integration of these components and ensure that they work together as expected. In the process, we will also make a more engaging, interactive application for evaluating our combined creation locally. + +## About the Battery-Charger Integration +In our previous integration exercise, we realized we needed to restructure much of our project structure to allow proper code accessibility to build the integration. +Refactoring is a normal part of a development process as complexity grows and patterns of component interoperability begin to emerge. +We did restructure the code in that effort. However, for the most part, we simply moved ahead with the same service integration and message handling established with the very first component creation. This included introducing ownership-rule defying patterns such as the `duplicate_static_mut!` copy macro that allowed us to get around Rust rules for double-borrow. We could assert that this was safe because we could audit all the uses ourselves and verify that no harm would come, even though Rust's static analysis, unable to share that birdseye view of things, would not agree. But these forms of assertions all too easily become overconfident declarations of hubris and just because we _say_ something is safe, doesn't mean it is, especially when components begin getting plugged together in various new ways, and especially in an environment that strives for seamless interchangeability of component models. + +_After all, what is the point of the type-safe advantages in Rust when you choose to treat it like C?_ + +In this integration -- where we bring together all of the components we have created, we want to make sure we have a strong and defensible integration model design before +we move on to embedded targeting where flaws in our design will be less tolerated. + +Several parts of our previous integrations, on review, are flawed: +- The already mentioned use of `unsafe` code workarounds and inconsistent ownership patterns. +- Unnecessary use of Generics when constructing components. Generics come with additional overhead and are more complicated to write for, so use of them superficially should be discouraged. +- Failure to use the `battery-service` event processing - even though we created and registered our BatteryDevice, we didn't start the service that uses it. + +## A more unified structure +A problem we have seen that quickly becomes even more complicated as we bring this integration together is the issue of a single, unified ownership scope. We've already noted how having separate component instances that we try to pass around to various worker tasks runs quickly into the multiple borrow violations problem. + +To combat this more structurally, we'll define a single structure, `ControllerCore`, that will own all of the components directly, and access to this at a task level will be managed by a mutex to ensure we don't run into any race condition behavior. These patterns are enforceable by Rust's static analysis, so if it complains, we know we've crossed a line and shouldn't resort to cheating with `unsafe` casts or else we will face consequences. + +>## New approach benefits +> - single owner `ControllerCore` +> - consolidated BusEvent channel for messages +> - OnceLock + Mutex pattern +> - removal of gratuitous generics +> ---- + +### Breaking some eggs +Addressing these changes will require some minor revisions in our previous definitions for `MockBatteryController` and `MockChargerController`. Although the changes are minor, they will have significant impact upon the previous projects and they will no longer build. As they say, making omelets requires breaking some eggs. These past projects could be resurrected by adopting some of the new constructor patterns we will introduce here, but that will be left as an exercise for the reader. + +## A simulation +We will build this integration as both an integration test and as an executable app that runs the simulation of the components in action. This simulator will allow us to increase/decrease the load, mimicking the behavior of a real system, and we can then observe how the components interact with each other to keep the battery charged and the system cool over differing operating conditions. + +### Setting up the integration project +We will set up a new project space for this integration, rather than trying to shoehorn it into the existing battery or charger projects. This will allow us to keep the integration code separate from the component code, making it easier to manage and test. + +Create a new project directory in the `ec_examples` directory named `integration_project`. Give it a `Cargo.toml` file with the following content: + +```toml +# Integration Project +[package] +name = "integration_project" +version = "0.1.0" +edition = "2024" +resolver = "2" +description = "System-level integration sim wiring Battery, Charger, and Thermal" + + +[dependencies] +embedded-batteries-async = { workspace = true } +embassy-executor = { workspace = true } +embassy-time = { workspace = true } +embassy-sync = { workspace = true } +embassy-futures = { workspace = true } +embassy-time-driver = { workspace = true } +embassy-time-queue-utils = { workspace = true } + +embedded-services = { workspace = true } +battery-service = { workspace = true } +embedded-sensors-hal-async = {workspace = true} + +ec_common = { path = "../ec_common"} +mock_battery = { path = "../battery_project/mock_battery", default-features = false} +mock_charger = { path = "../charger_project/mock_charger", default-features = false} +mock_thermal = { path = "../thermal_project/mock_thermal", default-features = false} + +static_cell = "2.1" +futures = "0.3" +heapless = "0.8" +crossterm = "0.27" + +[features] +default = ["std", "thread-mode"] +std = [] +thread-mode = [ + "mock_battery/thread-mode", + "mock_charger/thread-mode", + "mock_thermal/thread-mode" +] +noop-mode = [ + "mock_battery/noop-mode", + "mock_charger/noop-mode", + "mock_thermal/noop-mode" +] +``` + +Next, edit the `ec_examples/Cargo.toml` at the top level to add `integration_project` as a workspace member: + +```toml + members = [ + "battery_project/mock_battery", + "charger_project/mock_charger", + "thermal_project/mock_thermal", + "battery_charger_subsystem", + "integration_project", + "ec_common" +] +``` + +_As a reminder, the whole of `ec_examples/Cargo.toml` looks like this:_ + +```toml +# ec_examples/Cargo.toml +[workspace] +resolver = "2" +members = [ + "battery_project/mock_battery", + "charger_project/mock_charger", + "thermal_project/mock_thermal", + "battery_charger_subsystem", + "integration_project", + "ec_common" +] + +[workspace.dependencies] +embedded-services = { path = "embedded-services/embedded-service" } +battery-service = { path = "embedded-services/battery-service" } +embedded-batteries = { path = "embedded-batteries/embedded-batteries" } +embedded-batteries-async = { path = "embedded-batteries/embedded-batteries-async" } +embedded-cfu-protocol = { path = "embedded-cfu" } +embedded-usb-pd = { path = "embedded-usb-pd" } + +thermal-service = { path = "embedded-services/thermal-service" } +embedded-sensors-hal-async = { path = "embedded-sensors/embedded-sensors-async"} +embedded-fans-async = { path = "embedded-fans/embedded-fans-async"} + +embassy-executor = { path = "embassy/embassy-executor", features = ["arch-std", "executor-thread"], default-features = false } +embassy-time = { path = "embassy/embassy-time", features=["std"], default-features = false } +embassy-sync = { path = "embassy/embassy-sync", features = ["std"] } +embassy-futures = { path = "embassy/embassy-futures" } +embassy-time-driver = { path = "embassy/embassy-time-driver", default-features = false} +embassy-time-queue-utils = { path = "embassy/embassy-time-queue-utils" } +embedded-hal = "1.0" +embedded-hal-async = "1.0" +once_cell = "1.19" +static_cell = "2.1.0" +defmt = "1.0" +log = "0.4.27" +bitfield = "0.19.1" +bitflags = "1.0" +bitvec = "1.0" +cfg-if = "1.0" +chrono = "0.4.41" +tokio = { version = "1.45", features = ["full"] } +uuid = "1.0" +critical-section = {version = "1.0", features = ["std"] } +document-features = "0.2.11" +embedded-hal-nb = "1.0" +embedded-io = "0.6.1" +embedded-io-async = "0.6.1" +embedded-storage = "0.3.1" +embedded-storage-async = "0.4.1" +fixed = "1.0" +heapless = "0.8.0" +postcard = "1.0" +rand_core = "0.9.3" +serde = "1.0" +cortex-m = "0.7.7" +cortex-m-rt = "0.7.5" + +[patch.crates-io] +embassy-executor = { path = "embassy/embassy-executor" } +embassy-time = { path = "embassy/embassy-time" } +embassy-sync = { path = "embassy/embassy-sync" } +embassy-futures = { path = "embassy/embassy-futures" } +embassy-time-driver = { path = "embassy/embassy-time-driver" } +embassy-time-queue-utils = { path = "embassy/embassy-time-queue-utils" } +embedded-batteries-async = { path = "embedded-batteries/embedded-batteries-async" } + +# Lint settings for the entire workspace. +# We start with basic warning visibility, especially for upcoming Rust changes. +# Additional lints are listed here but disabled by default, since enabling them +# may trigger warnings in upstream submodules like `embedded-services`. +# +# To tighten enforcement over time, you can uncomment these as needed. +[workspace.lints.rust] +warnings = "warn" # Show warnings, but do not fail the build +future_incompatible = "warn" # Highlight upcoming breakage (future Rust versions) +# rust_2018_idioms = "warn" # Enforce idiomatic Rust style (may warn on legacy code) +# unused_crate_dependencies = "warn" # Detect unused deps — useful during cleanup +# missing_docs = "warn" # Require documentation for all public items +# unsafe_code = "deny" # Forbid use of `unsafe` entirely + +[patch.'https://github.com/embassy-rs/embassy'] +embassy-time = { path = "./embassy/embassy-time" } +embassy-time-driver = { path = "./embassy/embassy-time-driver" } +embassy-sync = { path = "./embassy/embassy-sync" } +embassy-executor = { path = "./embassy/embassy-executor" } +embassy-futures = { path = "./embassy/embassy-futures" } +``` + +Now we can get on with the changes to our existing code to make things ready for this integration, starting with defining some structures for configuration to give us parametric control of behavior and policy. + diff --git a/guide_book/src/how/ec/integration/10-controller_core.md b/guide_book/src/how/ec/integration/10-controller_core.md new file mode 100644 index 0000000..e89a821 --- /dev/null +++ b/guide_book/src/how/ec/integration/10-controller_core.md @@ -0,0 +1,413 @@ +# ControllerCore + +Now we are ready to implement the core of our integration, the `ControllerCore` structure and its associated tasks and trait implementations. This is where the bulk of our integration logic will reside. + +Our `ControllerCore` implementation will consist of four primary areas of concern: + +1. basic implementation of collected components +2. Controller trait implementation +3. spawned tasks, including listeners that accept messages +4. handlers that conduct the actions related to messages received. + +The first two of these are necessary to implement in order to create a minimally viable first test. + +Let's start out with the basic implementation of `contoller_core.rs` by starting with this code: +```rust +use mock_battery::mock_battery_controller::MockBatteryController; +use mock_charger::mock_charger_controller::MockChargerController; +use mock_thermal::mock_sensor_controller::MockSensorController; +use mock_thermal::mock_fan_controller::MockFanController; +use crate::config::ui_config::RenderMode; +use crate::system_observer::SystemObserver; +use crate::entry::{BatteryChannelWrapper, ChargerChannelWrapper, InteractionChannelWrapper, ThermalChannelWrapper}; + +use battery_service::controller::{Controller, ControllerEvent}; +use battery_service::device::{DynamicBatteryMsgs, StaticBatteryMsgs}; +use embassy_time::Duration; +use mock_battery::mock_battery::MockBatteryError; +use embedded_batteries_async::smart_battery::{ + SmartBattery, + ManufactureDate, SpecificationInfoFields, CapacityModeValue, CapacityModeSignedValue, + BatteryModeFields, BatteryStatusFields, + DeciKelvin, MilliVolts +}; + +use embedded_services::power::policy::charger::Device as ChargerDevice; // disambiguate from other device types +use embedded_services::power::policy::PowerCapability; +use embedded_services::power::policy::charger::PolicyEvent; +use embedded_services::power::policy::charger::ChargerResponseData; + +use embedded_sensors_hal_async::temperature::TemperatureThresholdSet; + +use ec_common::mutex::{Mutex, RawMutex}; +use crate::display_models::StaticValues; +use crate::events::{BusEvent, InteractionEvent}; +use ec_common::events::{ThermalEvent, ThresholdEvent}; +use embedded_services::power::policy::charger::{ChargerEvent, PsuState}; +use embassy_sync::channel::{Channel, Sender, Receiver, TrySendError}; + +use embassy_executor::Spawner; + +use embedded_batteries_async::charger::{Charger, MilliAmps}; +use embedded_services::power::policy::charger::{ + ChargeController,ChargerError +}; +use mock_charger::mock_charger::MockChargerError; + +const BUS_CAP: usize = 32; + +use crate::config::AllConfig; +use crate::state::{ChargerState, ThermalState, SimState}; + +#[allow(unused)] +pub struct ControllerCore { + // device components + pub battery: MockBatteryController, // controller tap is owned by battery service wrapper + pub charger: MockChargerController, + pub sensor: MockSensorController, + pub fan: MockFanController, + // for charger service + pub charger_service_device: &'static ChargerDevice, + + // comm busses + pub battery_channel: &'static BatteryChannelWrapper, // owned by setup and shared + pub charger_channel: &'static ChargerChannelWrapper, + pub thermal_channel: &'static ThermalChannelWrapper, + pub interaction_channel: &'static InteractionChannelWrapper, + + tx:Sender<'static, RawMutex, BusEvent, BUS_CAP>, + + // ui observer + pub sysobs: &'static SystemObserver, // owned by setup and shared + + // configuration + pub cfg: AllConfig, + + // state + pub sim: SimState, + pub therm: ThermalState, + pub chg: ChargerState + +} + +static BUS: Channel = Channel::new(); + +impl ControllerCore { + pub fn new( + battery: MockBatteryController, + charger: MockChargerController, + sensor: MockSensorController, + fan: MockFanController, + charger_service_device: &'static ChargerDevice, + battery_channel: &'static BatteryChannelWrapper, + charger_channel: &'static ChargerChannelWrapper, + thermal_channel: &'static ThermalChannelWrapper, + interaction_channel: &'static InteractionChannelWrapper, + sysobs: &'static SystemObserver, + ) -> Self + { + Self { + battery, charger, sensor, fan, + charger_service_device, + battery_channel, charger_channel, thermal_channel, interaction_channel, + tx: BUS.sender(), + sysobs, + cfg: AllConfig::default(), + sim: SimState::default(), + therm: ThermalState::default(), + chg: ChargerState::default() + } + } + + // === API for message senders === + /// No-await event emit + #[allow(unused)] + pub fn try_send(&self, evt: BusEvent) -> Result<(), TrySendError> { + self.tx.try_send(evt) + } + + /// Awaiting send for must-deliver events. + #[allow(unused)] + pub async fn send(&self, evt: BusEvent) { + self.tx.send(evt).await + } + + /// start event processing with a passed mutex + pub fn start(core_mutex: &'static Mutex, spawner: Spawner) { + + println!("In ControllerCore::start (fn={:p})", Self::start as *const ()); + } +} +``` + +Now, you will recall that we created `BatteryAdapter` as a structure implementing all the traits required for it to serve as the component registered for the Battery Service (via the `BatteryWrapper`), and that this implementation simply passed these traits along to this `ControllerCore` instance, so we must necessarily implement all those trait methods here in `ControllerCore` as well. Since we have our actual Battery object contained here, we can forward these in turn to that component, thus attaching it to the Battery Service. But along the way, we get the opportunity to "tap into" this relay and use this opportunity to conduct our integration business. + +Let's go ahead and implement these traits by adding this code to `controller_core.rs` now. +This looks long, but most of it is just pass-through to the underlying battery and charger components (remember how extensive the `SmartBatter`y traits are): + +```rust +// ================= traits ================== +impl embedded_batteries_async::smart_battery::ErrorType for ControllerCore +{ + type Error = MockBatteryError; +} + +impl SmartBattery for ControllerCore +{ + async fn temperature(&mut self) -> Result { + self.battery.temperature().await + } + + async fn voltage(&mut self) -> Result { + self.battery.voltage().await + } + + async fn remaining_capacity_alarm(&mut self) -> Result { + self.battery.remaining_capacity_alarm().await + } + + async fn set_remaining_capacity_alarm(&mut self, v: CapacityModeValue) -> Result<(), Self::Error> { + self.battery.set_remaining_capacity_alarm(v).await + } + + async fn remaining_time_alarm(&mut self) -> Result { + self.battery.remaining_time_alarm().await + } + + async fn set_remaining_time_alarm(&mut self, v: u16) -> Result<(), Self::Error> { + self.battery.set_remaining_time_alarm(v).await + } + + async fn battery_mode(&mut self) -> Result { + self.battery.battery_mode().await + } + + async fn set_battery_mode(&mut self, v: BatteryModeFields) -> Result<(), Self::Error> { + self.battery.set_battery_mode(v).await + } + + async fn at_rate(&mut self) -> Result { + self.battery.at_rate().await + } + + async fn set_at_rate(&mut self, _: CapacityModeSignedValue) -> Result<(), Self::Error> { + self.battery.set_at_rate(CapacityModeSignedValue::MilliAmpSigned(0)).await + } + + async fn at_rate_time_to_full(&mut self) -> Result { + self.battery.at_rate_time_to_full().await + } + + async fn at_rate_time_to_empty(&mut self) -> Result { + self.battery.at_rate_time_to_empty().await + } + + async fn at_rate_ok(&mut self) -> Result { + self.battery.at_rate_ok().await + } + + async fn current(&mut self) -> Result { + self.battery.current().await + } + + async fn average_current(&mut self) -> Result { + self.battery.average_current().await + } + + async fn max_error(&mut self) -> Result { + self.battery.max_error().await + } + + async fn relative_state_of_charge(&mut self) -> Result { + self.battery.relative_state_of_charge().await + } + + async fn absolute_state_of_charge(&mut self) -> Result { + self.battery.absolute_state_of_charge().await + } + + async fn remaining_capacity(&mut self) -> Result { + self.battery.remaining_capacity().await + } + + async fn full_charge_capacity(&mut self) -> Result { + self.battery.full_charge_capacity().await + } + + async fn run_time_to_empty(&mut self) -> Result { + self.battery.run_time_to_empty().await + } + + async fn average_time_to_empty(&mut self) -> Result { + self.battery.average_time_to_empty().await + } + + async fn average_time_to_full(&mut self) -> Result { + self.battery.average_time_to_full().await + } + + async fn charging_current(&mut self) -> Result { + self.battery.charging_current().await + } + + async fn charging_voltage(&mut self) -> Result { + self.battery.charging_voltage().await + } + + async fn battery_status(&mut self) -> Result { + self.battery.battery_status().await + } + + async fn cycle_count(&mut self) -> Result { + self.battery.cycle_count().await + } + + async fn design_capacity(&mut self) -> Result { + self.battery.design_capacity().await + } + + async fn design_voltage(&mut self) -> Result { + self.battery.design_voltage().await + } + + async fn specification_info(&mut self) -> Result { + self.battery.specification_info().await + } + + async fn manufacture_date(&mut self) -> Result { + self.battery.manufacture_date().await + } + + async fn serial_number(&mut self) -> Result { + self.battery.serial_number().await + } + + async fn manufacturer_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.manufacturer_name(v).await + } + + async fn device_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_name(v).await + } + + async fn device_chemistry(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_chemistry(v).await + } +} + + +// helper works for Vec, &Vec, &[u8], [u8; N], heapless::String, etc. +fn to_string_lossy>(b: B) -> String { + String::from_utf8_lossy(b.as_ref()).into_owned() +} + +// Implement the same trait the wrapper expects. +impl Controller for ControllerCore { + + + type ControllerError = MockBatteryError; + + async fn initialize(&mut self) -> Result<(), Self::ControllerError> { + Ok(()) + } + + async fn get_static_data(&mut self) -> Result { + println!("🥳 >>>>> get_static_data has been called!!! <<<<<<"); + self.battery.get_static_data().await + + } + + async fn get_dynamic_data(&mut self) -> Result { + println!("🥳 >>>>> get_dynamic_data has been called!!! <<<<<<"); + self.battery.get_dynamic_data().await + } + + async fn get_device_event(&mut self) -> ControllerEvent { + println!("🥳 >>>>> get_device_event has been called!!! <<<<<<"); + core::future::pending().await + } + + async fn ping(&mut self) -> Result<(), Self::ControllerError> { + println!("🥳 >>>>> ping has been called!!! <<<<<<"); + self.battery.ping().await + + } + + fn get_timeout(&self) -> Duration { + println!("🥳 >>>>> get_timeout has been called!!! <<<<<<"); + self.battery.get_timeout() + } + + fn set_timeout(&mut self, duration: Duration) { + println!("🥳 >>>>> set_timeout has been called!!! <<<<<<"); + self.battery.set_timeout(duration) + } +} + +// --- charger --- +impl embedded_batteries_async::charger::ErrorType for ControllerCore +{ + type Error = MockChargerError; +} + +impl Charger for ControllerCore +{ + fn charging_current( + &mut self, + requested_current: MilliAmps, + ) -> impl core::future::Future> { + self.charger.charging_current(requested_current) + } + + fn charging_voltage( + &mut self, + requested_voltage: MilliVolts, + ) -> impl core::future::Future> { + self.charger.charging_voltage(requested_voltage) + } +} + +impl ChargeController for ControllerCore +{ + type ChargeControllerError = ChargerError; + + fn wait_event(&mut self) -> impl core::future::Future { + async move { ChargerEvent::Initialized(PsuState::Attached) } + } + + fn init_charger( + &mut self, + ) -> impl core::future::Future> { + self.charger.init_charger() + } + + fn is_psu_attached( + &mut self, + ) -> impl core::future::Future> { + self.charger.is_psu_attached() + } + + fn attach_handler( + &mut self, + capability: PowerCapability, + ) -> impl core::future::Future> { + self.charger.attach_handler(capability) + } + + fn detach_handler( + &mut self, + ) -> impl core::future::Future> { + self.charger.detach_handler() + } + + fn is_ready( + &mut self, + ) -> impl core::future::Future> { + self.charger.is_ready() + } +} +``` + +By adding these traits we satisfy the interface requirements for a battery-service / Battery Controller implementation and also as a Charger Controller. We have `println!` output in place to tell us when the Battery Controller traits are called from the battery-service. These will make a good first test. + +We have almost all the parts we need to run a simple test to see if things are wired up correctly. We just need to add a few final items to get everything started. Let's do that next. diff --git a/guide_book/src/how/ec/integration/11-first_tests.md b/guide_book/src/how/ec/integration/11-first_tests.md new file mode 100644 index 0000000..c012355 --- /dev/null +++ b/guide_book/src/how/ec/integration/11-first_tests.md @@ -0,0 +1,195 @@ +# First Tests + +Now let's go back to `entry.rs` and add a few more imports we will need: +```rust +use embassy_executor::Spawner; +use crate::display_models::Thresholds; +use mock_battery::virtual_battery::VirtualBatteryState; +use crate::events::RenderMode; +use crate::events::DisplayEvent; +use crate::events::InteractionEvent; +use crate::system_observer::SystemObserver; +// use crate::display_render::display_render::DisplayRenderer; + +// Task imports +use crate::setup_and_tap::{ + setup_and_tap_task +}; +``` +And now we want to add the entry point that is called by `main()` here (in `entry.rs`): +```rust +#[embassy_executor::task] +pub async fn entry_task_interactive(spawner: Spawner) { + println!("🚀 Interactive mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + // spawner.spawn(interaction_task(shared.interaction_channel)).unwrap(); + // spawner.spawn(render_task(shared.display_channel)).unwrap(); + +} +``` +Now we need to create and connect the `SystemObserver` within `entry.rs`. + +Below the other static allocations, add this line: +```rust + +static SYS_OBS: StaticCell = StaticCell::new(); + +``` +Uncomment this line to expose the observer property we are about to create +```rust +pub struct Shared { + // pub observer: &'static SystemObserver, + pub battery_channel: &'static BatteryChannelWrapper, +``` +and uncomment the creation of this in `init_shared()`: +```rust + // let observer = SYS_OBS.init(SystemObserver::new( + // Thresholds::new(), + // v_nominal_mv, + // display_channel + // )); +``` +as well as the reference to `observer` in the creation of `Shared` below that: +```rust + SHARED.get_or_init(|| SHARED_CELL.init(Shared { + // observer, + battery_channel, +``` + +Great! We are almost ready for our first test run. We just need to add some start up tasks to complete the work +in `setup_and_tap.rs`: + +Add these tasks: +```rust +// this will move ownership of ControllerTap to the battery_service, which will utilize the battery traits +// to call messages that we intercept ('tap') and thus can access the other components for messaging and simulation. +#[embassy_executor::task] +pub async fn battery_wrapper_task(wrapper: &'static mut Wrapper<'static, BatteryAdapter>) { + wrapper.process().await; +} + +#[embassy_executor::task] +pub async fn battery_start_task() { + use battery_service::context::{BatteryEvent, BatteryEventInner}; + use battery_service::device::DeviceId; + + println!("🥺 Doing battery service startup -- DoInit followed by PollDynamicData"); + + // 1) initialize (this will Ping + UpdateStaticCache, then move to Polling) + let init_resp = battery_service::execute_event(BatteryEvent { + device_id: DeviceId(BATTERY_DEV_NUM), + event: BatteryEventInner::DoInit, + }).await; + + println!("battery-service DoInit -> {:?}", init_resp); + + // 2) get Static data first + let static_resp = battery_service::execute_event(BatteryEvent { + device_id: DeviceId(BATTERY_DEV_NUM), + event: BatteryEventInner::PollStaticData, + }).await; + if static_resp.is_err() { + eprintln!("Polling loop PollStaticData call to battery service failure!"); + } + + let delay:Duration = Duration::from_secs(3); + let interval:Duration = Duration::from_millis(250); + + embassy_time::Timer::after(delay).await; + + loop { + // 3) now poll dynamic (valid only in Polling) + let dyn_resp = battery_service::execute_event(BatteryEvent { + device_id: DeviceId(BATTERY_DEV_NUM), + event: BatteryEventInner::PollDynamicData, + }).await; + if let Err(e) = &dyn_resp { + eprintln!("Polling loop PollDynamicData call to battery service failure! (pretty) {e:#?}"); + } + embassy_time::Timer::after(interval).await; + } + +} +``` +Starting the `battery_wrapper_task` is what binds our battery controller to the battery-service where it awaits command messages to begin its orchestration. We kick this off in `battery_start_task` by giving it the expected sequence of starting messages, placing it into the _polling_ mode where we can continue the pump to receive repeated dynamic data reports. + +### Including modules +If you haven't already, be sure to include the new modules in `main.rs`. The set of modules named here should include: +```rust +mod config; +mod policy; +mod model; +mod state; +mod events; +mod entry; +mod setup_and_tap; +mod controller_core; +mod display_models; +mod battery_adapter; +mod system_observer; +``` + + +At this point, you should be able to do a `cargo check` and get a successful build without errors -- you'll get a lot of warnings because there are a number of unused imports and references we haven't attached yet, but you can ignore those for now. + +If you run the program with `cargo run`, you should see this output: +``` +🚀 Interactive mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🥳 >>>>> get_timeout has been called!!! <<<<<< +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start (fn=0x7ff6425f9860) +spawning charger_policy_event_task +spawning controller_core_task +spawning start_charger_task +spawning integration_listener_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +🥳 >>>>> get_timeout has been called!!! <<<<<< +battery-service DoInit -> Ok(Ack) +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> get_static_data has been called!!! <<<<<< +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> get_dynamic_data has been called!!! <<<<<< +🥳 >>>>> get_timeout has been called!!! <<<<<< +🥳 >>>>> get_dynamic_data has been called!!! <<<<<< +``` +with the last few lines repeating endlessly. Press `ctrl-c` to exit. + +Congratulations! This means that the scaffolding is all in place and ready for the continuation of the implementation. + +Let's pause here to review what is actually happening at this point. + +### Review of operation so far +1. `main()` calls `entry_task_interactive()`, which initializes the shared handles and spawns the `setup_and_tap_task()`. +2. `setup_and_tap_task()` initializes embedded-services, spawns the battery service task, constructs and registers the mock devices and controllers, and finally spawns the `ControllerCore::start()` task. +3. `ControllerCore::start()` initializes the controller core, spawns the charger policy event task, the controller core task, the start charger task, and the integration listener task. +4. Meanwhile, back in `entry_task_interactive()`, after spawning `setup_and_tap_task()`, it waits for the battery fuel service to signal that it is ready, which happens at the end of `setup_and_tap_task()`. +5. The `battery_start_task()` is spawned as part of `setup_and_tap_task ()`, which initializes the battery service by sending it a `DoInit` event, followed by a `PollStaticData` event, and then enters a loop where it continuously sends `PollDynamicData` events to the battery service at regular intervals. This is what drives the periodic updates of battery data in our integration. +6. The battery service, upon receiving the `DoInit` event, calls the `ping()` and `get_timeout()` methods of our `BatteryAdapter`, which in turn call into the `ControllerCore` to handle these requests. The battery service then transitions to the polling state. +7. The `PollStaticData` and `PollDynamicData` events similarly call into the `BatteryAdapter`, which forwards these calls to the `ControllerCore`, which will eventually handle these requests and return the appropriate data to the battery service. +8. The `ControllerCore` also has tasks running that listen for charger policy events and other integration events, although these are not yet fully implemented. + +As we see here, the operational flow is driven through the battery service's polling mechanism, where we we tap the `get_dynamic_data()` calls to access the `ControllerCore` and shared comm channels to facilitate the integration of the various components to work together. + +To do that, we will next implement the listeners and handlers within the ControllerCore to respond to these calls and to manage the interactions between the battery, charger, sensor, and fan components. + diff --git a/guide_book/src/how/ec/integration/12-tasks_and_listeners.md b/guide_book/src/how/ec/integration/12-tasks_and_listeners.md new file mode 100644 index 0000000..b6b60c3 --- /dev/null +++ b/guide_book/src/how/ec/integration/12-tasks_and_listeners.md @@ -0,0 +1,261 @@ +# Tasks, Listeners, and Handlers + +So, our first test shows us our nascent scaffolding is working. We see the the `println!` output from our `ControllerCore` tapped trait methods, and we see the pump continue to run through tap point of `get_dynamic_data()`. + +We can use this tap point to orchestrate interaction with the other components. But before we do that, we need to establish an independent way to communicate with these components through our message channels. Although we are within the ControllerCore context and have direct access to the component methods, we want to preserve the modularity of our components and keep them isolated from each other. Messages allow us to do this without locking ourselves into a tightly-coupled design. + +>---- +> ### Rule of Thumb -- locking the core +> - __Lock once, copy out, unlock fast.__ Read all the field you need locally then release the lock before computation or I/O. +> - __Never hold a lock across `.await`.__ Extract data you'll need, drop the guard, _then_ `await`. +> - __Prefer one short lock over many tiny locks.__ It reduces contention and avoids inconsistent snapshots. +> +> --- + +Let's start with the general listening task of the `ControllerCore`. This task will listen for messages on the channels we have established, and then forward these messages to the appropriate handlers. + +Add this to `controller_core.rs`: + +```rust +// ==== General event listener task ===== +#[embassy_executor::task] +pub async fn controller_core_task(receiver:Receiver<'static, RawMutex, BusEvent, BUS_CAP>, core_mutex: &'static Mutex) { + + loop { + let event = receiver.receive().await; + match event { + BusEvent::Charger(e) => handle_charger(core_mutex, e).await, + BusEvent::Thermal(e) => handle_thermal(core_mutex, e).await, + BusEvent::ChargerPolicy(_) => handle_charger_policy(core_mutex, event).await, + } + } +} +``` +and add the spawn for this task in the `start()` method of `ControllerCore`: + +```rust + /// start event processing with a passed mutex + pub fn start(core_mutex: &'static Mutex, spawner: Spawner) { + + println!("In ControllerCore::start()"); + + println!("spawning controller_core_task"); + if let Err(e) = spawner.spawn(controller_core_task(BUS.receiver(), core_mutex)) { + eprintln!("spawn controller_core_task failed: {:?}", e); + } + } +``` + +This establishes a general listener task that will receive messages from the bus and forward them to specific handlers. We will define these handlers next. Add these handler functions to `controller_core.rs`: + +```rust +async fn handle_charger(core_mutex: &'static Mutex, event: ChargerEvent) { + + let device = { + let core = core_mutex.lock().await; + core.charger_service_device + }; + + match event { + ChargerEvent::Initialized(PsuState::Attached) => { + } + + ChargerEvent::PsuStateChange(PsuState::Attached) => { + println!(" ☄ attaching charger"); + let _ = device.execute_command(PolicyEvent::InitRequest).await; // let the policy attach and ramp per latest PowerConfiguration. + } + + ChargerEvent::PsuStateChange(PsuState::Detached) | + ChargerEvent::Initialized(PsuState::Detached) => { + println!(" ✂ detaching charger"); + let zero_cap = PowerCapability {voltage_mv: 0, current_ma: 0}; + let _ = device.execute_command(PolicyEvent::PolicyConfiguration(zero_cap)).await; // should detach with this. + } + + ChargerEvent::Timeout => { + println!("⏳ Charger Timeout occurred"); + } + ChargerEvent::BusError => { + println!("❌ Charger Bus error occurred"); + } + } +} + +async fn handle_charger_policy(core_mutex: &'static Mutex, evt: BusEvent) { + match evt { + BusEvent::ChargerPolicy(cap)=> { + + // Treat current==0 as a detach request + if cap.current_ma == 0 { + let mut core = core_mutex.lock().await; + let _ = core.charger.detach_handler().await; + let _ = core.charger.charging_current(0).await; + } else { + let mut core = core_mutex.lock().await; + // Make sure we’re “attached” at the policy layer + let _ = core.charger.attach_handler(cap).await; + + // Program voltage then current; the mock should update its internal state + let _ = core.charger.charging_voltage(cap.voltage_mv).await; + let _ = core.charger.charging_current(cap.current_ma).await; + } + + // echo what the mock reports now + if is_log_mode(core_mutex).await { + let core = core_mutex.lock().await; + let now = { core.charger.charger.state.lock().await.current() }; + println!("🔌 Applied {:?}; charger now reports {} mA", cap, now); + } + } + _ => {} + } +} + +async fn handle_thermal(core_mutex: &'static Mutex, evt: ThermalEvent) { + match evt { + ThermalEvent::TempSampleC100(cc) => { + let temp_c = cc as f32 / 100.0; + { + let mut core = core_mutex.lock().await; + core.sensor.sensor.set_temperature(temp_c); + } + } + + ThermalEvent::Threshold(th) => { + match th { + ThresholdEvent::OverHigh => println!(" ⚠🔥 running hot"), + _ => {} + } + } + + ThermalEvent::CoolingRequest(req) => { + let mut core = core_mutex.lock().await; + let policy = core.cfg.policy.thermal.fan_policy; + let cur_level = core.therm.fan_level; + let (res, _rpm) = core.fan.handle_request(cur_level, req, &policy).await.unwrap(); + core.therm.fan_level = res.new_level; + } + } +} +``` +We can see that these handlers are fairly straightforward. It is here that we _do_ call into the integrated component internals, _after_ receiving the message that directs the action. Each handler locks the `ControllerCore` mutex, and then call the appropriate methods on the components. The implementation of these actions is very much like what we have done in the previous integrations. One notable difference, however, is in `handle_charger` we call upon the registered `charger_service_device` to execute the `PolicyEvent` commands. We do this to take advantage of the charger policy handling that is built into the embedded-services charger device. This allows us to offload some of the policy management to the embedded-services layer, which is a good thing. In previous integrations, we chose to implement this ourselves. Both approaches are valid, but using the built-in policy handling allows for a predictable and repeatable behavior that is consistent with other embedded-services based implementations. + +## The Charger Task and Charger Policy Task +On that subject, it's not enough to just call `device_command` on the charger device when we receive a `ChargerEvent`. We also need to start the charger service and have a task that listens for charger policy events and sends those to the charger device. This is because the charger policy events may be generated from other parts of the system, such as the battery service or the thermal management system, and we need to have a dedicated task to handle these events. + +Let's add those two tasks now: +```rust +// helper for log mode check +pub async fn is_log_mode(core_mutex: &'static Mutex) -> bool { + let core = core_mutex.lock().await; + core.cfg.ui.render_mode == RenderMode::Log +} + +#[embassy_executor::task] +async fn start_charger_task(core_mutex: &'static Mutex) { + + let p = is_log_mode(core_mutex).await; + let device = { + let core = core_mutex.lock().await; + core.charger_service_device + }; + + if p {println!("start_charger_task");} + if p {println!("waiting for yield");} + // give a tick to start before continuing to avoid possible race + embassy_futures::yield_now().await; + + // Now issue commands and await responses + if p {println!("issuing CheckReady and InitRequest to charger device");} + let _ = device.execute_command(PolicyEvent::CheckReady).await; + let _ = device.execute_command(PolicyEvent::InitRequest).await; +} + +// ==== Charger subsystem event listener ==== +#[embassy_executor::task] +pub async fn charger_policy_event_task(core_mutex: &'static Mutex) { + + let p = is_log_mode(core_mutex).await; + let device = { + let core = core_mutex.lock().await; + core.charger_service_device + }; + + loop { + match device.wait_command().await { + PolicyEvent::CheckReady => { + if p {println!("Charger PolicyEvent::CheckReady received");} + let res = { + let mut core = core_mutex.lock().await; + core.charger.is_ready().await + } + .map(|_| Ok(ChargerResponseData::Ack)) + .unwrap_or_else(|_| Err(ChargerError::Timeout)); + device.send_response(res).await; + } + PolicyEvent::InitRequest => { + if p {println!("Charger PolicyEvent::InitRequest received");} + let res = { + let mut core = core_mutex.lock().await; + core.charger.init_charger().await + } + .map(|_| Ok(ChargerResponseData::Ack)) + .unwrap_or_else(|_| Err(ChargerError::BusError)); + device.send_response(res).await; + } + PolicyEvent::PolicyConfiguration(cap) => { + if p {println!("Charger PolicyEvent::PolicyConfiguration received {:?}", cap);} + device.send_response(Ok(ChargerResponseData::Ack)).await; // ack so caller can continue + let core = core_mutex.lock().await; + if core.try_send(BusEvent::ChargerPolicy(cap)).is_err() { + eprintln!("⚠️ Dropped ChargerPolicy event (bus full)"); + } + } + } + } +} +``` + + +> --- +> ### Rule of thumb --`send` vs `try_send` +> - Use `send` when in an async context for must-deliver events (rare, low-rate control/path): it awaits and guarantees delivery order. +> - Use `try_send` for best effort or high-rate events, or from a non-async context. It returns immediately. Check the error for failure if the bus is full. +> - If dropping is unacceptable but backpressure is possible, keep retrying +> - Log drops from `try_send` to catch buffer capacity issues early on. +> +> ---- + + +You may have noticed that we also snuck in a helper function `is_log_mode()` to check if we are in log mode. This is used to control the verbosity of the output from these tasks. This will make more sense once we have the display and interaction system in place. + +We also need to spawn these tasks in the `start()` method of `ControllerCore`. Add these spawns to the `start()` method: + +```rust + println!("spawning start_charger_task"); + if let Err(e) = spawner.spawn(start_charger_task(core_mutex)) { + eprintln!("spawn start_charger_task failed: {:?}", e); + } + println!("spawning charger_policy_event_task"); + if let Err(e) = spawner.spawn(charger_policy_event_task(core_mutex)) { + eprintln!("spawn charger_policy_event_task failed: {:?}", e); + } +``` + +### Starting values for thermal policy +Our thermal policy respects temperature thresholds to determine when to request cooling actions. We have established these thresholds in the configuration, but we need to set them into action before we begin. We can do this at the top of our `controller_core_task()` function, before we enter the main loop: + +```rust + // set initial temperature thresholds + { + let mut core = core_mutex.lock().await; + let lo_temp_threshold = core.cfg.policy.thermal.temp_low_on_c; + let hi_temp_threshold = core.cfg.policy.thermal.temp_high_on_c; + if let Err(e) = core.sensor.set_temperature_threshold_low(lo_temp_threshold) { eprintln!("temp low set failed: {e:?}"); } + if let Err(e) = core.sensor.set_temperature_threshold_high(hi_temp_threshold) { eprintln!("temp high set failed: {e:?}"); } + } +``` +We do this inside of a block to limit the scope of the mutex lock. This is a good practice to avoid holding locks longer than necessary. + + +Now the handling for charger and thermal events are in place. Now we can begin to implement the integration logic that binds these components together. diff --git a/guide_book/src/how/ec/integration/13-integration_logic.md b/guide_book/src/how/ec/integration/13-integration_logic.md new file mode 100644 index 0000000..9c4b38c --- /dev/null +++ b/guide_book/src/how/ec/integration/13-integration_logic.md @@ -0,0 +1,74 @@ +# Integration Logic + +The `get_dynamic_data()` method is our tap point for integration logic. However, for code organization if nothing else, we will be placing all the code for this into a new file `integration_logic.rs` and calling into it from the `get_dynamic_data()` interception point. + +Create `integration_logic.rs` and give it this content to start for now: + +```rust +use battery_service::controller::Controller; +use battery_service::device::DynamicBatteryMsgs; +use crate::controller_core::ControllerCore; +use mock_battery::mock_battery::MockBatteryError; + + +pub async fn integration_logic(core: &mut ControllerCore) -> Result { + let dd = core.battery.get_dynamic_data().await?; + println!("integration_logic: got dynamic data: {:?}", dd); + Ok(dd) +} +``` + +add this module to `main.rs`: + +```rust +mod integration_logic; +``` + +Now, modify the `get_dynamic_data()` method in `controller_core.rs` to call into this new function: + +```rust + async fn get_dynamic_data(&mut self) -> Result { + println!("ControllerCore: get_dynamic_data() called"); + crate::integration_logic::integration_logic(self).await + } +``` +And while we are in the area, let's comment out the `println!` statement for the `get_timeout()` trait method. We know that the battery-service calls this frequently to get the timeout duration, but we don't need to see that in our output every time: +```rust + fn get_timeout(&self) -> Duration { + // println!("🥳 >>>>> get_timeout has been called!!! <<<<<<"); + self.battery.get_timeout() + } +``` + +If we run the program now with `cargo run`, we should see output like this: +``` +🚀 Interactive mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start() +spawning controller_core_task +spawning start_charger_task +spawning charger_policy_event_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +battery-service DoInit -> Ok(Ack) +🥳 >>>>> get_static_data has been called!!! <<<<<< +ControllerCore: get_dynamic_data() called +integration_logic: got dynamic data: DynamicBatteryMsgs { max_power_mw: 0, sus_power_mw: 0, full_charge_capacity_mwh: 4800, remaining_capacity_mwh: 4800, relative_soc_pct: 100, cycle_count: 0, voltage_mv: 4200, max_error_pct: 1, battery_status: 0, charging_voltage_mv: 0, charging_current_ma: 0, battery_temp_dk: 2982, current_ma: 0, average_current_ma: 0 } +ControllerCore: get_dynamic_data() called +integration_logic: got dynamic data: DynamicBatteryMsgs { max_power_mw: 0, sus_power_mw: 0, full_charge_capacity_mwh: 4800, remaining_capacity_mwh: 4800, relative_soc_pct: 100, cycle_count: 0, voltage_mv: 4200, max_error_pct: 1, battery_status: 0, charging_voltage_mv: 0, charging_current_ma: 0, battery_temp_dk: 2982, current_ma: 0, average_current_ma: 0 } +``` +with the data dump from `get_dynamic_data()` repeated on each poll. + +Before we start to get involved in the details of the integration logic, let's pivot to the display and interaction side of things. We will need to have those pieces in place to be able to see the results of our integration logic as we develop it. diff --git a/guide_book/src/how/ec/integration/14-display_rendering.md b/guide_book/src/how/ec/integration/14-display_rendering.md new file mode 100644 index 0000000..51332e0 --- /dev/null +++ b/guide_book/src/how/ec/integration/14-display_rendering.md @@ -0,0 +1,498 @@ +# Display Rendering + +Earlier on, we introduced the `SystemObserver` structure, which is responsible for observing and reporting on the state of the system. One of its key features is the ability to render a display output, which we can implement in various ways, from a simple console output to a more complex graphical interface or a test reporting system. + +The mechanism we will use for this will be embodied in a structure we will call `DisplayRenderer`. This structure will be responsible for taking the data from the `SystemObserver` and rendering it in a way that is useful for our purposes. + +## DisplayRenderer +Because we will have more than one implementation of `DisplayRenderer`, we will define a trait that all implementations must satisfy. This trait will define the methods that must be implemented by any structure that wishes to be a `DisplayRenderer`. + +create a new folder in the `src` directory called `display_render`, and within that folder create a new file called `mod.rs`. In `mod.rs`, add the following: + +```rust +// display_render +pub mod display_render; +pub mod log_render; +pub mod in_place_render; +``` +Then, in this display_render folder, create empty files for `display_render.rs`, `log_render.rs`, and `in_place_render.rs`. + +In `display_render.rs`, we will define the `DisplayRenderer` trait and some helper methods that address common rendering tasks and display mode switching. + +```rust +use crate::events::RenderMode; +use crate::display_models::{DisplayValues, InteractionValues, StaticValues}; +use crate::display_render::in_place_render::InPlaceBackend; +use crate::display_render::log_render::LogBackend; +use crate::events::DisplayEvent; +use crate::entry::DisplayChannelWrapper; + + +// Define a trait for the interface for a rendering backend +pub trait RendererBackend : Send + Sync { + fn on_enter(&mut self, _last: Option<&DisplayValues>) {} + fn on_exit(&mut self) {} + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues); + fn render_static(&mut self, sv: &StaticValues); +} + +// ---- the renderer that can hot-swap backends ---- + +pub struct DisplayRenderer { + backend: Box, + mode: RenderMode, + last_frame: Option, +} + +impl DisplayRenderer { + pub fn new(initial: RenderMode) -> Self { + let mut me = Self { + backend: Self::make_backend(initial), + mode: initial, + last_frame: None + }; + me.backend.on_enter(None); + me + } + + fn make_backend(mode: RenderMode) -> Box { + match mode { + RenderMode::InPlace => Box::new(InPlaceBackend::new()), + RenderMode::Log => Box::new(LogBackend::new()), + } + } + + pub fn set_mode(&mut self, mode: RenderMode) { + if self.mode == mode { return; } + self.backend.on_exit(); + self.backend = Self::make_backend(mode); + self.backend.on_enter(self.last_frame.as_ref()); + self.mode = mode; + } + + pub fn toggle_mode(&mut self) { + if self.mode == RenderMode::InPlace { + self.set_mode(RenderMode::Log); + } else { + self.set_mode(RenderMode::InPlace); + } + } + + pub fn quit(&mut self) { + self.backend.on_exit(); + std::process::exit(0) + } + + + + pub async fn run(&mut self, display_rx: &'static DisplayChannelWrapper) -> ! { + loop { + match display_rx.receive().await { + DisplayEvent::Update(dv, ia) => { + // println!("Display update received {:?}", dv); + self.backend.render_frame(&dv, &ia); + self.last_frame = Some(dv); + }, + DisplayEvent::Static(sv) => { + // println!("Static update received {:?}", sv); + self.backend.render_static(&sv); + }, + DisplayEvent::ToggleMode => { + self.toggle_mode(); + }, + DisplayEvent::Quit => { + self.quit(); + } + } + } + } +} + +// common helper +pub fn time_fmt_from_ms(ms: f32) -> String { + let total_secs = (ms / 1000.0).floor() as u64; // floor = truncation + let m = total_secs / 60; + let s = total_secs % 60; + format!("{:02}:{:02}", m, s) +} +``` + +As you see, the `DisplayRenderer` offloads the actual rendering to a `RendererBackend`, which is a trait object that can be swapped out at runtime. The `DisplayRenderer` manages the current mode and handles events from the `DisplayChannelWrapper` through its `run()` method task. + +> ### Why traits for the renderer? +> Using a `RendererBackend` trait gives us: +> - __Hot-swappable backends__ - The `DisplayRenderer` pushes the same events into any of the implemented backends (`Log`, `InPlace`, `IntegrationTest`), so choice of rendering becomes an injection choice, not a refactor. +> - __Clean Testing__ - The `IntegrationTest` backend will consume the exact same UI pipe as the interactive designs, so tests are more easily able to exercise the true event flow. +> - __Tighter Coupling Where it belongs__ - `SystemObserver` doesn't know or care about ANSI control code or log formatting. That bit of implementation detail lives entirely within the individual renderers. +> - __Smaller and simpler than generics__ - A boxed trait object that can be injected avoids monomorphization bloat and keeps the API stable. +> - __Single Responsibility__ - Backends implement a small surface keeping the respective code cohesive and easier to reason over. +> +> ---- + +```mermaid +flowchart LR + IChan[InteractionChannel] --> Obs[SystemObserver] + Obs --> DChan[DisplayChannel] + DChan --> RSel[DisplayRenderer] + + subgraph Backends + direction TB + Log[Log renderer] + InPlace[InPlace renderer] + Test[IntegrationTest renderer] + end + + RSel --> Log + RSel --> InPlace + RSel --> Test +``` + +We later will create two implementations of `RendererBackend`: `InPlaceBackend` and `LogBackend` (ultimately, we will add a third for test reporting), but we'll start with a simple `LogBackend` that just logs the display updates to the console first. + +In `log_render.rs`, add the following: + +```rust + +use crate::display_render::display_render::{RendererBackend, time_fmt_from_ms}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; +use embassy_time::Instant; + +pub struct LogBackend; +impl LogBackend { pub fn new() -> Self { Self } } +impl RendererBackend for LogBackend { + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues) { + let (speed_number, speed_multiplier) = ia.get_speed_number_and_multiplier(); + let time_str = time_fmt_from_ms(dv.sim_time_ms); + let rt_ms = Instant::now().as_millis(); + println!( + "[{}]({} {}) {} - SOC {:>5.1}% | Draw {:>5.1}W | Chg {:>5.1}W | Net {:>+5.1}W | T {:>4.1}°C | Fan L{} {}% {}rpm", + rt_ms, speed_number, speed_multiplier, time_str, + dv.soc_percent, dv.draw_watts, dv.charge_watts, dv.net_watts, dv.temp_c, dv.fan_level, + dv.fan_percent, dv.fan_rpm + ); + } + fn render_static(&mut self, sv: &StaticValues) { + println!("{} {} #{}, {} mWh, {} mV [{}]", sv.battery_mfr, sv.battery_name, sv.battery_serial, sv.battery_dsgn_cap_mwh, sv.battery_dsgn_voltage_mv, sv.battery_chem); + } +} + +``` +This `LogBackend` simply prints the display updates to the console in a formatted manner. It also prints static information about the battery when it receives a `Static` event. + +We will also create a non-functional stub for now for the `InPlaceBackend` in `in_place_render.rs`, so that we can compile and run our code without errors. We will implement the actual in-place rendering later. +```rust +use crate::display_render::display_render::{RendererBackend, time_fmt_from_ms}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; + +pub struct InPlaceBackend; +impl InPlaceBackend { pub fn new() -> Self { Self } } +impl RendererBackend for InPlaceBackend { + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues) { + } + fn render_static(&mut self, sv: &StaticValues) { + } +} +``` + +### Adding the display_render module +Now, we need to add the `display_render` module to our project. In `main.rs`, add the following line to include the new module: + +```rust +mod display_render; +``` + +## Implementing the Display Task +The `SystemObserver` will send `DisplayEvent`s to the `DisplayRenderer` through a channel. The `run` method of `DisplayRenderer` is designed to be run as an async task that continuously listens for events and processes them. So, we need to create a task that will instantiate a `DisplayRenderer` and run it. We will do this in `entry.rs`. + +Open `entry.rs` and look at the `entry_task_interactive` startup function. From our previous work, there is likely a commented-out line that spawns a `render_task`. Uncomment that line (or add it if it's not there): + +```rust + spawner.spawn(render_task(shared.display_channel)).unwrap(); +``` +Then, add the `render_task` function to `entry.rs`, to call the `DisplayEvent` listener in `DisplayRenderer`: + +```rust +#[embassy_executor::task] +pub async fn render_task(rx: &'static DisplayChannelWrapper) { + let mut r = DisplayRenderer::new(RenderMode::Log); + r.run(rx).await; +} +``` +and add the necessary import at the top of `entry.rs`: + +```rust +use crate::display_render::display_render::DisplayRenderer; +``` + +Our display is driven by `DisplayEvent` messages. These events are sent by the `SystemObserver` when it has new data to display or when the display mode needs to change. There are separate messages for sending static data and for sending dynamic updates. The updates for dynamic data will only be rendered when there is a change in the data, to avoid unnecessary output. + +### Signaling static data +In our output we see that `get_static_data()` is called only once at startup, so we can start by collecting and sending the static display data to `SystemObserver` so that it can forward it to the `DisplayRenderer`. + +In `ControllerCore`, find the `get_static_data()` method and modify it like so: +```rust + async fn get_static_data(&mut self) -> Result { + let sd = self.battery.get_static_data().await?; + + let mfr = to_string_lossy(&sd.manufacturer_name); + let name = to_string_lossy(&sd.device_name); + let chem = to_string_lossy(&sd.device_chemistry); + let serial = serial_bytes_to_string(&sd.serial_num); + + let cap_mwh: u32 = sd.design_capacity_mwh; + let volt_mv: u16 = sd.design_voltage_mv; + + self.sysobs.set_static(StaticValues { + battery_mfr: mfr, + battery_name: name, + battery_chem: chem, + battery_serial: serial, + battery_dsgn_cap_mwh: cap_mwh, + battery_dsgn_voltage_mv: volt_mv + }).await; + Ok(sd) + } +``` +Above the Controller trait implementations, you will find the existing helper function `to_string_lossy()`, which is used here. Add the following helper to correctly convert the byte array that comprises the battery serial number into a string for display: +```rust +// helper to convert serial number bytes to a string +fn serial_bytes_to_string(serial: &[u8]) -> String { + match serial { + // Common case from MockBatteryController: [0, 0, hi, lo] + [0, 0, hi, lo] => u16::from_be_bytes([*hi, *lo]).to_string(), + // Gracefully handle a plain 2-byte value too: [hi, lo] + [hi, lo] => u16::from_be_bytes([*hi, *lo]).to_string(), + // Fallback: take the last two bytes as big-endian + bytes if bytes.len() >= 2 => { + let hi = bytes[bytes.len() - 2]; + let lo = bytes[bytes.len() - 1]; + u16::from_be_bytes([hi, lo]).to_string() + } + // Nothing usable + _ => String::from(""), + } +} +``` +The format of the serial number byte array is not strictly defined, so we handle a few common cases here. We had encountered this in our first implementation example of the Battery. The "serial number" of our Virtual Mock Battery is defined in that code. + +If we run the program now with `cargo run`, we should see a single line of output representing the static battery information, just following the battery-service DoInit acknowledgment, and before the repeating sequence of dynamic data updates: +``` +MockBatteryCorp MB-4200 #258, 5000 mWh, 7800 mV [LION] +``` +Now let's implement the dynamic data updates. + +Back in `integration_logic.rs`, modify the `integration_logic()` function to organize the dynamic data we receive from the battery and map it to the DisplayValues structure to send on to the `SystemObserver`. In this first case, all we will really relay is the state of charge (SOC) percentage, we'll use placeholder zero values for the rest of the fields for now: +```rust +use crate::display_models::DisplayValues; + +pub async fn integration_logic(core: &mut ControllerCore) -> Result { + let dd = core.battery.get_dynamic_data().await?; + let ia = core.sysobs.interaction_snapshot().await; + core.sysobs.update(DisplayValues { + sim_time_ms: 0.0, + soc_percent: dd.relative_soc_pct as f32, + temp_c: 0.0, + fan_level: 0, + fan_percent: 0, + fan_rpm: 0, + load_ma: 0, + charger_ma: 0, + net_batt_ma: 0, + draw_watts: 0.0, + charge_watts: 0.0, + net_watts: 0.0, + }, ia).await; + + Ok(dd) +} +``` +When the program is run now, we will get the static data, followed by a single dynamic data update report, and a number of `ControllerCore: get_dynamic_data() called` `println!` echoes after that, but no further display updates. This is because the SOC is not changing, so there is no need to send further updates to the display. + +We can remove the `println!` statement in `get_dynamic_data()` in `controller_core.rs` now, so we will only get the single report, but to have it change over time we will need to actually start attaching the simulation and integration logic. + +### Battery simulation and simulated time +You no doubt will recall that since our original `VirtualBattery` implementation, we have have an implementation named `tick()` that simulates the passage of time and the effect of load and charge on the battery state. We can continue to use this to drive our integration. We might also choose this opportunity to rewrite this simulation in a more sophisticated and potentially more accurate way, within this integration, but for now, let's just use what we have. + +You will recall that the `tick()` method is called with a parameter that indicates how many milliseconds of simulated time to advance. We can use this to drive our simulation forward in a controlled manner. The `tick()` method also takes a parameter that indicates the current in milliamps (mA) being drawn from or supplied to the battery. A positive value indicates charging, while a negative value indicates discharging. This turns out to be a little bit awkward in our present integration, but we work around it. + +Let's add the following code to the top of the `integration_logic()` function, before we call `get_dynamic_data()`: +```rust + // timing first + let speed_multiplier = { core.sysobs.interaction_snapshot().await.sim_speed_multiplier }; + let inow = Instant::now(); + let dt_s = ((inow - core.sim.last_update).as_millis() as f32 * speed_multiplier)/1000.0; + + // simulated time is real-time seconds * multiplier + let sim_time_ms = inow.as_millis() as f32 * speed_multiplier; + let now_ms = sim_time_ms as u64; // use this instead of Instant::now for time-based references + core.sim.last_update = inow; + + // inputs + let mut act_chg_ma = { core.charger.charger.state.lock().await.current() } as i32; + let soc = { core.battery.battery.state.lock().await.relative_soc_percent }; + let load_ma = { core.sysobs.interaction_snapshot().await.system_load as i32 }; + + // no charge from detached charger + if !core.chg.was_attached { + act_chg_ma = 0; + } + + let net_ma_i32 = (act_chg_ma - load_ma).clamp(-20_000, 20_000); + let net_ma = net_ma_i32 as i16; + + // mutate the model first + { + let mut bstate = core.battery.battery.state.lock().await; + bstate.set_current(net_ma); + bstate.tick(0, dt_s); + } +``` +You will need to add the following import at the top of `integration_logic.rs`: +```rust +use embassy_time::Instant; +``` +We can also change the first two fields given to DisplayValues for update to come from our new calculated values: +```rust + core.sysobs.update(DisplayValues { + sim_time_ms, + soc_percent: soc as f32, +``` +Now, when we run the program with `cargo run`, we should see the SOC percentage changing over time, along with the simulated time in the log output: +``` +MockBatteryCorp MB-4200 #258, 5000 mWh, 7800 mV [LION] +[3007](3 25) 01:15 - SOC 100.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[3259](3 25) 01:21 - SOC 99.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[9424](3 25) 03:55 - SOC 98.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[15591](3 25) 06:29 - SOC 97.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +[21740](3 25) 09:03 - SOC 96.0% | Draw 0.0W | Chg 0.0W | Net +0.0W | T 0.0°C | Fan L0 0% 0rpm +``` +The timing displayed in the left columns are the real-time milliseconds since the program started in [ ] brackets, followed by the speed setting and speed multiplier in ( ) parenthesis, and then the simulated time in MM:SS format. +Later, interaction control will allow us to change the speed of simulated time. + +For now, notice that the SOC percentage is decreasing over time as expected. We can also see that the other fields in the display output are still zero, as we have not yet implemented their calculation and updating. + +### Charger policy behaviors + +So now, let's attach some charger policy. Remember that we have a `charger_policy.rs` file that we defined earlier,that contains some key functions we can use now. + +In `integration_logic.rs`, just above where we call `core.sysobs.update()`, add the following code to apply the charger policy: +```rust + let mv = dd.voltage_mv; + + // --- charger policy: target + boost + slew + let watts_deficit = round_w_01(w_from_ma_mv((load_ma as f32 - act_chg_ma as f32) as i32, mv)); + let base_target = derive_target_ma(&core.cfg.policy.charger, &core.cfg.sim.device_caps, soc, load_ma); + let boost = p_boost_ma(core.cfg.policy.charger.kp_ma_per_w, core.cfg.policy.charger.p_boost_cap_ma, watts_deficit); + let target_ma = base_target.saturating_add(boost); + core.chg.requested_ma = slew_toward(core.chg.requested_ma, target_ma, dt_s, core.cfg.policy.charger.max_slew_ma_per_s); + + // Send PolicyConfiguration only when needed + if core.chg.was_attached { + let since_ms = now_ms - core.chg.last_policy_sent_at_ms; + let gap = (core.chg.requested_ma as i32 - act_chg_ma).unsigned_abs() as u16; + if since_ms >= core.cfg.policy.charger.policy_min_interval_ms + && gap >= core.cfg.policy.charger.policy_hysteresis_ma { + let cap = build_capability(&core.cfg.policy.charger, &core.cfg.sim.device_caps, soc, core.chg.requested_ma); + let _ = core.charger_service_device.execute_command(PolicyEvent::PolicyConfiguration(cap)).await; + core.chg.last_policy_sent_at_ms = now_ms; + } + } + + // --- PSU attach/detach decision + let margin = core.cfg.policy.charger.heavy_load_ma; + if load_ma > margin { + core.chg.last_heavy_load_ms = now_ms + } + + let ad = decide_attach( + &core.cfg.policy.charger, + core.chg.was_attached, + soc, + core.chg.last_psu_change_ms, + core.chg.last_heavy_load_ms, + sim_time_ms as u64 + ); + if ad.do_change { + let ev = if ad.attach { PsuState::Attached } else { PsuState::Detached }; + core.send(BusEvent::Charger(ChargerEvent::PsuStateChange(ev))).await; + core.chg.was_attached = ad.attach; + core.chg.last_psu_change_ms = now_ms; + } + +``` +We'll have to bring in these imports in order to get to our charger policy functions, and the format helpers from our display models: +```rust +use crate::policy::charger_policy::{derive_target_ma, p_boost_ma, slew_toward, build_capability, decide_attach}; +use crate::display_models::{round_w_01, w_from_ma_mv}; +use crate::events::BusEvent; +use embedded_services::power::policy::charger::PolicyEvent; +use embedded_services::power::policy::charger::{ChargerEvent,PsuState}; +``` +and this will give us new values we can use to populate our DisplayValues structure: +```rust + load_ma: load_ma as u16, + charger_ma: act_chg_ma as u16, + net_batt_ma: net_ma as i16, + draw_watts: round_w_01(w_from_ma_mv(load_ma, mv)), + charge_watts: round_w_01(w_from_ma_mv(act_chg_ma, mv)), + net_watts: round_w_01(w_from_ma_mv(net_ma as i32, mv)), +``` + +Now we should see some policy behavior in action. If we run the program with `cargo run`, we should see the SOC percentage decreasing over time, and when it reaches the attach threshold, the charger should attach, and we should see the charge current and charge watts increase, and the SOC should start to increase again. The charger will detach when the battery reaches full charge and then the cycle repeats itself. + +This behavior is roughly equivalent to what we saw in our earlier integration attempts, but now we have a more structured and modular approach to handling the display rendering and the integration logic. We'll cap this off next by adding the thermal considerations. + +### Thermal Policy + +We also created some thermal policy functions in `thermal_governor.rs` earlier. We can use these to manage the fan speed based on the battery temperature. Let's grab what we need for imports: +```rust +use crate::model::thermal_model::step_temperature; +use crate::policy::thermal_governor::process_sample; +use mock_thermal::mock_fan_controller::level_to_pwm; +``` + +and add this code above the `// --- PSU attach/detach decision` comment in `integration_logic()`: +```rust + // --- thermal model + governor + let new_temp = step_temperature( + core.sensor.sensor.get_temperature(), + load_ma, + core.therm.fan_level, + &core.cfg.sim.thermal, + dt_s + ); + let c100 = (new_temp * 100.0).round().clamp(0.0, 65535.0) as u16; + let _ = core.try_send(BusEvent::Thermal(ThermalEvent::TempSampleC100(c100))); + + let hi_on = core.cfg.policy.thermal.temp_high_on_c; + let lo_on = core.cfg.policy.thermal.temp_low_on_c; + let td = process_sample( + new_temp, + core.therm.hi_latched, core.therm.lo_latched, + hi_on, lo_on, + core.cfg.policy.thermal.fan_hyst_c, + core.therm.last_fan_change_ms, core.cfg.policy.thermal.fan_min_dwell_ms, + now_ms, + ); + core.therm.hi_latched = td.hi_latched; + core.therm.lo_latched = td.lo_latched; + if !matches!(td.threshold_event, ThresholdEvent::None) { + core.send(BusEvent::Thermal(ThermalEvent::Threshold(td.threshold_event))).await; + } + if let Some(req) = td.cooling_request { + core.send(BusEvent::Thermal(ThermalEvent::CoolingRequest(req))).await; + if td.did_step { core.therm.last_fan_change_ms = now_ms; } + } +``` +and then we can set the following `DisplayValues` fields for temperature and fan status: +```rust + temp_c: new_temp, + fan_level: core.therm.fan_level as u8, + fan_percent: level_to_pwm(core.therm.fan_level, core.cfg.sim.thermal.max_fan_level), + fan_rpm: core.fan.fan.current_rpm(), +``` + +We now have all the components integrated and reporting. But nothing too exciting is happening because we only have a consistent load on the system that we've established at the start. + +It's time to introduce some interactive UI. + diff --git a/guide_book/src/how/ec/integration/15-interaction.md b/guide_book/src/how/ec/integration/15-interaction.md new file mode 100644 index 0000000..10c6899 --- /dev/null +++ b/guide_book/src/how/ec/integration/15-interaction.md @@ -0,0 +1,139 @@ +# Interaction + +Our integration is fine, but what we really want to see here is how our component work together in a simulated system. To create a meaningful simulation of a system, we need to add some interactivity so that we can see how the system responds to user inputs and changes in state, in particular, increases and decreases to the system load the battery/charger system is supporting. + +If we return our attention to `entry.rs`, we see in `entry_task_interactive()` a commented-out spawn of an `interaction_task()`: +```rust + // spawner.spawn(interaction_task(shared.interaction_channel)).unwrap(); +``` +remove the comment characters to enable this line (or add the line if it is not present). Then add this task and helper functions: +```rust +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use embassy_time::{Duration, Timer}; + +#[embassy_executor::task] +pub async fn interaction_task(tx: &'static InteractionChannelWrapper) { + + loop { + // crossterm input poll for key events + if event::poll(std::time::Duration::from_millis(0)).unwrap_or(false) { + if let Ok(Event::Key(k)) = event::read() { + handle_key(k, tx).await; + } + } + // loop timing set to be responsive, but allow thread relief + Timer::after(Duration::from_millis(33)).await; + } +} + +async fn handle_key(k:KeyEvent, tx:&'static InteractionChannelWrapper) { + if k.kind == KeyEventKind::Press { + match k.code { + KeyCode::Char('>') | KeyCode::Char('.') | KeyCode::Right => { + tx.send(InteractionEvent::LoadUp).await + }, + KeyCode::Char('<') | KeyCode::Char(',') | KeyCode::Left => { + tx.send(InteractionEvent::LoadDown).await + }, + KeyCode::Char('1') => { + tx.send(InteractionEvent::TimeSpeed(1)).await + }, + KeyCode::Char('2') => { + tx.send(InteractionEvent::TimeSpeed(2)).await + }, + KeyCode::Char('3') => { + tx.send(InteractionEvent::TimeSpeed(3)).await + }, + KeyCode::Char('4') => { + tx.send(InteractionEvent::TimeSpeed(4)).await + }, + KeyCode::Char('5') => { + tx.send(InteractionEvent::TimeSpeed(5)).await + } + KeyCode::Char('D') | KeyCode::Char('d') => { + tx.send(InteractionEvent::ToggleMode).await + } + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => { + tx.send(InteractionEvent::Quit).await + }, + _ => {} + } + } + +} +``` +As you see, this sends `InteractionEvent` messages in response to key presses. The `InteractionEvent` enum is defined in `events.rs`. The handling for these events is also already in place in `system_observer.rs` in the `update()` method of `SystemObserver`. The `LoadUp` and `LoadDown` events adjust the system load, and the `TimeSpeed(n)` events adjust the speed of time progression in the simulation. The `ToggleMode` event switches between normal and silent modes, and the `Quit` event exits the simulation. + +What's needed to complete this cycle is a listener for these events in our main integration logic. We have already set up the `InteractionChannelWrapper` and passed it into our `ControllerCore` as `sysobs`. Now we need to add the event listening to the `ControllerCore` task. + +Add the listener task in `controller_core.rs`: +```rust +// ==== Interaction event listener task ===== +#[embassy_executor::task] +pub async fn interaction_listener_task(core_mutex: &'static Mutex) { + + let receiver = { + let core = core_mutex.lock().await; + core.interaction_channel + }; + + loop { + let event = receiver.receive().await; + match event { + InteractionEvent::LoadUp => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.increase_load().await; + }, + InteractionEvent::LoadDown => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.decrease_load().await; + }, + InteractionEvent::TimeSpeed(s) => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.set_speed_number(s).await; + }, + InteractionEvent::ToggleMode => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.toggle_mode().await; + }, + InteractionEvent::Quit => { + let sysobs = { + let core = core_mutex.lock().await; + core.sysobs + }; + sysobs.quit().await; + } + } + } +} +// (display event listener found in display_render.rs) +``` + +Call it from the `ControllerCore::start()` method, just after we spawn the `controller_core_task`: +```rust + println!("spawning integration_listener_task"); + if let Err(e) = spawner.spawn(interaction_listener_task(core_mutex)) { + eprintln!("spawn controller_core_task failed: {:?}", e); + } +``` + +You will notice that all of the handling for the interaction events is done through the `SystemObserver` instance that is part of `ControllerCore`. `SystemObserver` has helper methods both for sending the event messages and for handling them, mostly by delegating to other members. This keeps the interaction logic nicely encapsulated. + +Running now, we can use the key actions to raise or lower the system load, and change the speed of time progression. When we are done, we can hit `q` or `Esc` to exit the simulation instead of resorting to `ctrl-c`. + +### An improved experience +We have so far only implemented the `RenderMode::Log` version of the display renderer. This was a simple renderer to create while we were focused on getting the integration working, and it remains a valuable tool for logging the system state in a way that provides a reviewable perspective of change over time. But next, we are going to fill out the `RenderMode::InPlace` mode to provide a more interactive, app-like simulation experience. + + diff --git a/guide_book/src/how/ec/integration/16-in_place_render.md b/guide_book/src/how/ec/integration/16-in_place_render.md new file mode 100644 index 0000000..d904c89 --- /dev/null +++ b/guide_book/src/how/ec/integration/16-in_place_render.md @@ -0,0 +1,288 @@ +# In Place Rendering + +At the start of this integration example series, we discussed how this application would serve as output both for logging changes, as an interactive simulator display, and as an integration test. We have so far implemented the logging display mode, which provides a useful perspective on system state changes over time. But we also want to implement the in-place rendering mode, which will provide a more interactive experience. + +As you might guess, the key to implementing the in-place rendering mode lies in completing the implementation of the `display_render/in_place_render.rs` file. Like its already-completed counterpart, `log_render.rs`, this file implements the `DisplayRenderer` trait. The key difference is that instead of printing out log lines, it will use terminal control codes to update the display in place. + +### ANSI Escape Codes +The `InPlace` mode will use ANSI escape codes to control the terminal display. These are special sequences of characters that the terminal interprets as commands rather than text to display. For example, the sequence `\x1B[2J` clears the screen, and `\x1B[H` moves the cursor to the home position (top-left corner). By using these codes, we can create a dynamic display that updates in place. We will also make use of colors to enhance the visual experience, and use percentage bars to represent values as well as numerical data. + +### ANSI Helpers and support +We will start our `in_place_render.rs` implementation by establishing some helper functions and definitions we will use for our rendering. + +Replace the current placeholder content of `in_place_render.rs` with the following code. There are a lot of definitions here that define the specific escape code patterns to achieve ANSI terminal effects and colors. There's also some helper code for rendering pseudo-graphical elements using these techniques. Don't worry too much about these now: +```rust +use crate::display_render::display_render::{RendererBackend, time_fmt_from_ms}; +use crate::display_models::{StaticValues,DisplayValues,InteractionValues,Thresholds}; + +// ==== helpers for ANSI positional rendering ==== + +#[inline] +fn goto(row: u16, col: u16) { print!("\x1b[{};{}H", row, col); } // 1-based +#[inline] +fn clear_line() { print!("\x1b[K");} +#[inline] +fn hide_cursor() { print!("\x1b[?25l"); } +#[inline] +fn show_cursor() { print!("\x1b[?25h"); } +#[inline] +fn clear_screen() { print!("\x1b[2J\x1b[H"); } // clear + home + +// ==== ANSI helpers ==== +#[inline] fn reset() -> &'static str { "\x1b[0m" } +#[inline] fn bold() -> &'static str { "\x1b[1m" } + +#[inline] fn panel() { print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); } +#[inline] fn clear_line_panel() { panel(); clear_line(); } +#[inline] fn line_start(row: u16, col: u16) { goto(row, col); clear_line_panel(); } + +#[inline] fn fg(code: u8) -> String { format!("\x1b[{}m", code) } // 30–37,90–97 +#[inline] fn bg(code: u8) -> String { format!("\x1b[{}m", code) } // 40–47,100–107 + +// 8/16-color palette picks that read well on most terminals: +const FG_DEFAULT: u8 = 97; // bright white +const BG_PANEL: u8 = 40; // black bg to isolate our content panel +const FG_GOOD: u8 = 92; // bright green +const BG_GOOD: u8 = 42; // green bg +const FG_WARN: u8 = 93; // bright yellow +const BG_WARN: u8 = 43; // yellow bg +const FG_DANGER: u8 = 91; // bright red +const BG_DANGER: u8 = 41; // red bg +const BG_EMPTY: u8 = 100; // bright black/gray for bar remainder + + +#[derive(Clone, Copy)] +struct ZoneColors { fg: u8, bg: u8 } + +/// Pick a color zone based on value relative to warn/danger. +/// If `good_is_high == true`, larger is greener (SOC, charge). +/// If `good_is_high == false`, larger is worse (Temp, Draw). +fn pick_zone(value: f32, warn: f32, danger: f32, good_is_high: bool) -> ZoneColors { + let (good, warn_c, danger_c) = ( + ZoneColors { fg: FG_GOOD, bg: BG_GOOD }, + ZoneColors { fg: FG_WARN, bg: BG_WARN }, + ZoneColors { fg: FG_DANGER, bg: BG_DANGER }, + ); + + if good_is_high { + if value <= danger { danger_c } + else if value <= warn { warn_c } + else { good } + } else { + // higher is worse: reverse comparisons + if value >= danger { danger_c } + else if value >= warn { warn_c } + else { good } + } +} + +/// Render a solid block bar with colorized background for the fill, +/// neutral gray background for the remainder, and visible brackets. +fn block_bar(frac: f32, width: usize, fill_zone: ZoneColors) -> String { + let frac = frac.clamp(0.0, 1.0); + let fill = core::cmp::min((frac * width as f32).round() as usize, width); + + let mut s = String::with_capacity(width + 10); + + // Bracket left in panel colors + s.push_str(&fg(FG_DEFAULT)); s.push_str(&bg(BG_PANEL)); s.push('['); + + // Filled segment + if fill > 0 { + s.push_str(&fg(fill_zone.fg)); + s.push_str(&bg(fill_zone.bg)); + for _ in 0..fill { s.push('█'); } + } + + // Empty remainder (neutral background for readability) + if fill < width { + s.push_str(&fg(FG_DEFAULT)); + s.push_str(&bg(BG_EMPTY)); + for _ in fill..width { s.push(' '); } + } + + // Bracket right back to panel bg + s.push_str(&fg(FG_DEFAULT)); s.push_str(&bg(BG_PANEL)); s.push(']'); + s.push_str(reset()); + s +} + + +// ====== +const ROW_TITLE: u16 = 1; +const ROW_HELP: u16 = 4; +const ROW_INFO1: u16 = 6; // manufacturer / name / serial / chem +const ROW_INFO2: u16 = 7; // voltage, capacity +const ROW_LINE: u16 = 8; // separator +const ROW_SOC: u16 = 9; // dynamic begins here +const ROW_DRAW: u16 = 10; +const ROW_CHG: u16 = 11; +const ROW_NET: u16 = 12; +const ROW_TEMP: u16 = 13; +const ROW_LINE2: u16 = 14; +const ROW_TIME: u16 = 15; +const ROW_LOG: u16 = 18; + +const COL_LEFT: u16 = 2; +const COL_SPEED: u16 = COL_LEFT + 30; +const COL_TIME: u16 = COL_LEFT + 58; +const BAR_W: usize = 36; +``` +This code will set us up with the basic building blocks we need to create our in-place rendering. We have defined a set of ANSI escape code helpers for cursor movement, screen clearing, and color setting. We have also defined some constants for colors that work well together on most terminals, as well as functions to pick colors based on value zones (good, warning, danger) and to render a block bar with colorized segments. +We have also defined constants for the row and column positions of various elements in our display, which will help us position our output correctly. + +Now we can implement our `InPlaceBackend` struct and its `RendererBackend` trait methods, including the all-important `render_frame()` method that updates dynamic changes to the display, and the `render_static()` method that sets up the static parts of the display at the beginning, and gives us a key command 'help' reference. + +```rust +pub struct InPlaceBackend { + th: Thresholds +} +impl InPlaceBackend { + pub fn new() -> Self { + Self { + th:Thresholds::new() + } + } +} +impl RendererBackend for InPlaceBackend { + fn on_enter(&mut self, last: Option<&DisplayValues>) { + // any setup or restore necessary + let _ = last; + clear_screen(); + hide_cursor(); + // Set a consistent panel background + default bright text + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + } + fn on_exit(&mut self) { + // teardown + print!("{}", reset()); + clear_screen(); + show_cursor(); + } + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues) { + // keep panel colors active + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + + let max_w = self.th.max_load.max(1.0); + + // Split for display only (both non-negative) + let draw_w = dv.draw_watts.max(0.0); + let charge_w = dv.charge_watts.max(0.0); + let net_w = dv.net_watts; + + let draw_frac = (draw_w / max_w).clamp(0.0, 1.0); + let charge_frac = (charge_w / max_w).clamp(0.0, 1.0); + let soc_frac = (dv.soc_percent / 100.0).clamp(0.0, 1.0); + + let (speed_number, speed_multiplier) = ia.get_speed_number_and_multiplier(); + + // === SOC (good is high: warn/danger are lower thresholds) === + let soc_zone = pick_zone( + dv.soc_percent, + self.th.warning_charge, + self.th.danger_charge, + true + ); + + line_start(ROW_SOC, COL_LEFT); + print!("SOC {:5.1}% ", dv.soc_percent); + println!("{}", block_bar(soc_frac, BAR_W, soc_zone)); + + // === Draw (higher is worse) === + let draw_zone = pick_zone( + draw_w, + self.th.max_load * 0.5, // tweakable: 50% = warn + self.th.max_load * 0.8, // tweakable: 80% = danger + false + ); + line_start(ROW_DRAW, COL_LEFT); + print!("Draw {:7.3} W {:5.1}% ", draw_w, draw_frac * 100.0); + println!("{}", block_bar(draw_frac, BAR_W, draw_zone)); + + // === Charge === + let chg_zone = pick_zone( + charge_w, + draw_w, // warn if only charging == draw + draw_w *0.8, // danger of running out if charge < draw + true + ); + line_start(ROW_CHG, COL_LEFT); + print!("Charge{:7.3} W {:5.1}% ", charge_w, charge_frac * 100.0); + println!("{}", block_bar(charge_frac, BAR_W, chg_zone)); + + // === Net (color arrow by direction) === + let dir = if net_w >= 0.0 { + format!("{}→ Charge{}", fg(FG_GOOD), reset()) + } else { + format!("{}← Draw{}", fg(FG_DANGER), reset()) + }; + // keep panel colors around printed arrow + let _ = print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + line_start(ROW_NET, COL_LEFT); + println!("Net {:+7.3} W {}", net_w, dir); + + // === Temp/Fan (higher is worse) === + let temp_zone = pick_zone( + dv.temp_c, + self.th.warning_temp, + self.th.danger_temp, + false + ); + line_start(ROW_TEMP, COL_LEFT); + print!("Temp {:5.1} °C ", dv.temp_c); + print!("{}{}Fan: ", fg(temp_zone.fg), bg(BG_PANEL)); + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + println!(" L{} ({}%) -- {} rpm", dv.fan_level, dv.fan_percent, dv.fan_rpm); + + // line + footer + line_start(ROW_LINE2, COL_LEFT); + println!("==============================================================="); + goto(ROW_TIME, COL_SPEED); clear_line(); + println!("Speed: {} ({} X)", speed_number, speed_multiplier); + goto(ROW_TIME, COL_TIME); clear_line(); + println!("{}", time_fmt_from_ms(dv.sim_time_ms)); + + // log area + line_start(ROW_LOG, COL_LEFT); + } + + fn render_static(&mut self, sv: &StaticValues) { + clear_screen(); + // re-assert panel colors + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + + goto(ROW_TITLE, COL_LEFT); + println!("==============================================================="); + goto(ROW_TITLE+1, COL_LEFT); + println!("{}ODP Component Integration Simulation{}", bold(), reset()); + print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); + goto(ROW_TITLE+2, COL_LEFT); + println!("==============================================================="); + goto(ROW_HELP, COL_LEFT); clear_line(); + println!("Keyboard commands: ESC to exit, < > to change load, 1-5 to set sim speed"); + goto(ROW_HELP+1, COL_LEFT); clear_line(); + println!("---------------------------------------------------------------"); + goto(ROW_INFO1, COL_LEFT); clear_line(); + println!("{} {} #{}", sv.battery_mfr, sv.battery_name, sv.battery_serial); + goto(ROW_INFO2, COL_LEFT); clear_line(); + println!("{} mWh, {} mV [{}]", sv.battery_dsgn_cap_mwh, sv.battery_dsgn_voltage_mv, sv.battery_chem); + goto(ROW_LINE, COL_LEFT); + println!("---------------------------------------------------------------"); + } +} +``` +Finally, our `entry.rs` `render_task()` function is currently set to the default of `RenderMode::Log`. We can change that to `RenderMode::InPlace` to use our new renderer: +```rust + let mut r = DisplayRenderer::new(RenderMode::InPlace); +``` +We can also toggle between log and in-place modes by pressing the 'D' key while the simulation is running, but this starts us of in an application-like mode right away. + +When you run your display should now look like this: +![in_place_render](./media/integration-sim.png) + +You can use the <> or left/right arrow keys to raise or lower the system load, and the number keys 1-5 to set the speed of time progression. The display updates in place, providing a more interactive experience. To toggle between this display and the logging mode, press `d`. When you are done, you can hit `q` or `Esc` to exit the simulation instead of resorting to `ctrl-c`. + +You now have a interactive simulation application to test the behavior of your integrated components over a range of conditions. This type of app is a powerful tool for understanding how your components work together, and for identifying any issues that may arise in real-world usage. + +Next up, we'll use what we have learned from these interactions to devise some automated tests to validate the integration in an unattended way. + diff --git a/guide_book/src/how/ec/integration/17-integration_test.md b/guide_book/src/how/ec/integration/17-integration_test.md new file mode 100644 index 0000000..c93a0f3 --- /dev/null +++ b/guide_book/src/how/ec/integration/17-integration_test.md @@ -0,0 +1,181 @@ +# Integration Test +At long last, we are at the integration test portion of this exercise -- along the way, we have created an integration that we can empirically run and evaluate, but a true integration test is automated, repeatable, and ideally part of a continuous integration (CI) process. We will create a test that runs the simulation for a short period of time, exercising the various components and their interactions, and then we will evaluate the results to ensure that they are as expected. + +A true integration test is invaluable in an environment where components are being actively developed, as it provides a way to ensure that changes in one component do not inadvertently break the overall system. It also provides a way to validate that the system as a whole is functioning as intended, and that the various components are interacting correctly. + +When things do begin to differ, one can use the interactive modes of an application such as this one to explore and understand the differences, and then make adjustments as needed. + +### Back to the DisplayRenderer +Our latest revision in the exercise was to create an in-place renderer that provides a more interactive experience. We can use the same mechanism to "render" to a testing construct that collects the results of simulated situations, evaluates them, and reports the results. + +This is similar to the Test Observer pattern used in previous examples, although adapted here for this new context. + +## Feature selection +We don't want our test mode "display" to be one of the toggle options of our simulation app. Rather, we want this to be selected at the start when we run the app in "integration-test" mode. So let's define some feature flags that will define our starting modes: + +So, before we even start defining our integration test support, let's posit that this will be a separately selectable compile and runtime mode that we want to designate with a `--features` flag. + +Our `Cargo.toml` already defines a `[features]` section that was mostly inherited from previous integration examples, and establishes the thread mode to use in different contexts. +We will keep that part of things intact so as not to interfere with the behavior of our dependent crates, +but we will extend it to introduce modes for `log-mode`, `in-place-mode` and `integration-test` mode, with `in-place-mode` being the default if no feature selection is made explicitly. + +In `Cargo.toml` +```toml +[features] +default = ["in-place-mode"] +integration-test = ["std", "thread-mode"] +log-mode = ["std", "thread-mode"] +in-place-mode = ["std", "thread-mode"] +std = [] +thread-mode = [ + "mock_battery/thread-mode", + "mock_charger/thread-mode", + "mock_thermal/thread-mode" +] +noop-mode = [ + "mock_battery/noop-mode", + "mock_charger/noop-mode", + "mock_thermal/noop-mode" +] +``` +Then, in `main.rs` we can use this to choose which of our starting tasks we wish to launch: +```rust +#[embassy_executor::main] +async fn main(spawner: Spawner) { + + #[cfg(feature = "integration-test")] + spawner.spawn(entry::entry_task_integration_test(spawner)).unwrap(); + + #[cfg(not(feature = "integration-test"))] + spawner.spawn(entry::entry_task_interactive(spawner)).unwrap(); +} +``` +This will set apart the integration test into a separate launch we will establish in `entry.rs` as well as +further separating the selection of `RenderMode::Log` vs. `RenderMode::InPlace` as the default to start with when not in test mode. + +In `entry.rs`, create the new entry task, and modify the render_task so that the `RenderMode` is passed in: +```rust +#[cfg(feature = "integration-test")] +#[embassy_executor::task] +pub async fn entry_task_integration_test(spawner: Spawner) { + println!("🚀 Integration test mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + spawner.spawn(render_task(shared.display_channel, RenderMode::IntegrationTest)).unwrap(); +} + +#[embassy_executor::task] +pub async fn render_task(rx: &'static DisplayChannelWrapper, mode:RenderMode) { + let mut r = DisplayRenderer::new(mode); + r.run(rx).await; +} +``` +Then, let's modify `entry_task_interactive` to respect the feature options for starting `RenderMode` as well: +```rust +#[embassy_executor::task] +pub async fn entry_task_interactive(spawner: Spawner) { + println!("🚀 Interactive mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + spawner.spawn(interaction_task(shared.interaction_channel)).unwrap(); + + #[cfg(feature = "log-mode")] + let mode = RenderMode::Log; + #[cfg(not(feature = "log-mode"))] + #[cfg(feature = "in-place-mode")] + let mode = RenderMode::InPlace; + + spawner.spawn(render_task(shared.display_channel, mode)).unwrap(); +} +``` + +### RenderMode::IntegrationTest + +We need to add the integration test mode to our `RenderMode` enum, and we need to create a placeholder for the rendering backend it will represent. + +In `events.rs`, modify the `RenderMode` enum to now be: +```rust +pub enum RenderMode { + InPlace, // ANSI Terminal application + Log, // line-based console output + IntegrationTest // Collector/Reporter for testing +} +``` + +Then create a new file in the `display_render` folder named `integration_test_render.rs` and give it this placeholder content for now: +```rust + +use crate::display_render::display_render::{RendererBackend}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; + +pub struct IntegrationTestBackend; +impl IntegrationTestBackend { pub fn new() -> Self { Self } } +impl RendererBackend for IntegrationTestBackend { + fn render_frame(&mut self, _dv: &DisplayValues, _ia: &InteractionValues) { + } + fn render_static(&mut self, _sv: &StaticValues) { + } +} +``` +this won't actually do anything more yet other than satisfy our traits for a valid backend renderer. + +we need to add this also to `display_render/mod.rs`: +``` +// display_render +pub mod display_render; +pub mod log_render; +pub mod in_place_render; +pub mod integration_test_render; +``` + +In `display_render.rs`, we can import this: +```rust +use crate::display_render::integration_test_render::IntegrationTestBackend; +``` + +and add it to the `match` statement of `make_backend()`: +```rust + fn make_backend(mode: RenderMode) -> Box { + match mode { + RenderMode::InPlace => Box::new(InPlaceBackend::new()), + RenderMode::Log => Box::new(LogBackend::new()), + RenderMode::IntegrationTest => Box::new(IntegrationTestBackend::new()) + } + } +``` + +now, we should be able to run in different modes from feature flags: + +``` +cargo run --features in-place-mode +``` +or simply +``` +cargo run +``` +should give us our ANSI "In Place" app-style rendering. +``` +cargo run --features log-mode +``` +should give us our log mode output from the start. +``` +cargo run --features integration-test +``` +should not emit anything past the initial `println!` statements up through `DoInit`, since we have a non-functional rendering implementation in place here. + +Next, let's explore how we want to conduct our integration tests. + + + diff --git a/guide_book/src/how/ec/integration/18-integration_test_structure.md b/guide_book/src/how/ec/integration/18-integration_test_structure.md new file mode 100644 index 0000000..4c2b517 --- /dev/null +++ b/guide_book/src/how/ec/integration/18-integration_test_structure.md @@ -0,0 +1,549 @@ +# Integration Test Structure + +Let's imagine a framework where we can set our expectations for our integration behavior over time or between states, then set the integration into motion where these expectations are tested, and get a report on what has passed and failed. We can repeat different sets of such tests until we are satisfied we have tested everything we want to. + +Such a framework would include a + +- a `TestReporter` that + - tracks the start and end of a testing period, checking to see if the period is complete + - records the evaluations that are to occur for this time period, and marks them as pass of fail + - reports the outcomes of these tests + +- a `Test entry` function that puts all of this into motion and defines the tests for each section and the scope of the tests. + +## Components of the TestReporter + +- TestResult enum +- Test structure, name, result, message +- evaluation trait +- assert helpers +- collection of Tests + +Let's create `test_reporter.rs` and give it this content: +```rust +// test_reporter.rs + +use std::fmt::{Display, Formatter}; +use std::time::Instant; + +/// Result of an evaluation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestResult { + Pending, + Pass, + Fail, +} +impl Default for TestResult { + fn default() -> Self { + Self::Pending + } +} +impl Display for TestResult { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + TestResult::Pending => write!(f, "PENDING"), + TestResult::Pass => write!(f, "PASS"), + TestResult::Fail => write!(f, "FAIL"), + } + } +} + +/// Per-test outcome produced by running an Evaluator. +#[derive(Debug, Clone)] +pub struct TestEval { + pub name: &'static str, + pub result: TestResult, + pub message: Option, + // pub elapsed: Option, +} +impl TestEval { + pub fn new(name: &'static str) -> Self { + Self { name, result: TestResult::Pending, message: None} + } +} + +/// Pass to mark pass/fail and attach messages. +#[derive(Debug, Default)] +pub struct TestObserver { + result: TestResult, + message: Option, +} +#[allow(unused)] +impl TestObserver { + // assume a test with no failures is considered passing. + pub fn new() -> Self { Self { result: TestResult::Pass, message: None } } + pub fn pass(&mut self) { self.result = TestResult::Pass; } + pub fn fail(&mut self, reason: impl Into) { + self.result = TestResult::Fail; + self.message = Some(reason.into()); + } + pub fn result(&self) -> TestResult { self.result } + pub fn message(&self) -> Option<&str> { self.message.as_deref() } +} + +/// Trait each test implements. `run` should set PASS/FAIL on the observer. +pub trait Evaluator: Send { + fn name(&self) -> &'static str; + fn run(&mut self, obs: &mut TestObserver); +} + +/// Helper: wrap a closure as an Evaluator. +pub struct FnEval { + name: &'static str, + f: Box, +} +impl FnEval { + pub fn new(name: &'static str, f: impl FnMut(&mut TestObserver) + Send + 'static) -> Self { + Self { name, f: Box::new(f) } + } +} +impl Evaluator for FnEval { + fn name(&self) -> &'static str { self.name } + fn run(&mut self, obs: &mut TestObserver) { (self.f)(obs) } +} + +/// A collection of tests plus section timing/reporting. +pub struct TestReporter { + tests: Vec>, + results: Vec, + section_start: Option, + section_end: Option, +} +#[allow(unused)] +impl TestReporter { + pub fn new() -> Self { + Self { tests: Vec::new(), results: Vec::new(), section_start: None, section_end: None } + } + + /// Register any Evaluator. + pub fn add_test(&mut self, eval: E) { + self.tests.push(Box::new(eval)); + } + + /// Convenience: register inline closures. + pub fn add_inline(&mut self, name: &'static str, f: impl FnMut(&mut TestObserver) + Send + 'static) { + self.add_test(FnEval::new(name, f)); + } + + /// Begin a new section: clears previous results but keeps registered tests + /// (so you can re-run same suite against evolving state). Call `clear_tests()` + /// if you want to rebuild the suite per section. + pub fn start_test_section(&mut self) { + self.results.clear(); + self.section_start = Some(Instant::now()); + self.section_end = None; + } + + /// Optionally rebuild the suite. + pub fn clear_tests(&mut self) { + self.tests.clear(); + self.results.clear(); + } + + /// Execute tests and capture results. + pub fn evaluate_tests(&mut self) { + self.results.clear(); + + for t in self.tests.iter_mut() { + let mut obs = TestObserver::new(); + let t_start = Instant::now(); + t.run(&mut obs); + // let elapsed = t_start.elapsed(); + + let result = obs.result; + + let mut ev = TestEval::new(t.name()); + ev.result = result; + ev.message = obs.message().map(|s| s.to_string()); + // ev.elapsed = Some(elapsed); + self.results.push(ev); + } + } + + /// End the section and print/report. + /// returns 0 on success or -1 on failures, which can be used as a reported error code for exit + pub fn end_test_section(&mut self) -> i32 { + self.section_end = Some(Instant::now()); + self.print_report() + } + + /// Aggregate and emit a summary report. Replace with your display backend as needed. + /// returns 0 on success or -1 on failures, which can be used as a reported error code for exit + pub fn print_report(&self) -> i32 { + let total = self.results.len(); + let passed = self.results.iter().filter(|r| r.result == TestResult::Pass).count(); + let failed = self.results.iter().filter(|r| r.result == TestResult::Fail).count(); + + let sec_elapsed = self.section_start + .zip(self.section_end) + .map(|(s, e)| e.duration_since(s)); + + println!("=================================================="); + println!(" Test Section Report"); + if let Some(d) = sec_elapsed { + println!(" Duration: {:?}\n", d); + } + + for r in &self.results { + // let time = r.elapsed.map(|d| format!("{:?}", d)).unwrap_or_else(|| "-".into()); + match (&r.result, &r.message) { + (TestResult::Pass, _) => { + println!("[PASS] {:<40}", r.name); + } + (TestResult::Fail, Some(msg)) => { + println!("[FAIL] {:<40} — {}", r.name, msg); + } + (TestResult::Fail, None) => { + println!("[FAIL] {:<40}", r.name); + } + (TestResult::Pending, _) => { + println!("[PEND] {:<40}", r.name); + } + } + } + println!("\n Summary: total={}, passed={}, failed={}", total, passed, failed); + println!("=================================================="); + + // return error code 0 == success, -1 == failure + if total == passed { 0 } else { -1 } + } + + /// Retrieve results programmatically (e.g., to feed a UI). + pub fn results(&self) -> &[TestEval] { &self.results } +} + +// Simple assertion macros + +/// Test a boolean expression +/// Usage: expect!(obs, is_true, _optional_message); +#[macro_export] +macro_rules! expect { + ($obs:expr, $cond:expr, $($msg:tt)*) => {{ + if !($cond) { + $obs.fail(format!($($msg)*)); + return; + } + }}; +} + +/// Compare two values for equality +/// Usage: expect_eq!(obs, actual, expected, _optional_message); +#[macro_export] +macro_rules! expect_eq { + ($obs:expr, $left:expr, $right:expr $(, $($msg:tt)*)? ) => {{ + if $left != $right { + let msg = format!( + concat!("expected == actual, but got:\n expected: {:?}\n actual: {:?}", $(concat!("\n ", $($msg)*))?), + &$right, &$left + ); + $obs.fail(msg); + return; + } + }}; +} + +/// Compare two numbers after rounding to `places` decimal places. +/// Usage: expect_to_decimal!(obs, actual, expected, places); +#[macro_export] +macro_rules! expect_to_decimal { + ($obs:expr, $actual:expr, $expected:expr, $places:expr $(,)?) => {{ + // Work in f64 for better rounding behavior, then compare the rounded integers. + let a_f64: f64 = ($actual) as f64; + let e_f64: f64 = ($expected) as f64; + let places_u: usize = ($places) as usize; + let scale: f64 = 10f64.powi(places_u as i32); + + let a_round_i = (a_f64 * scale).round() as i64; + let e_round_i = (e_f64 * scale).round() as i64; + + if a_round_i == e_round_i { + $obs.pass(); + } else { + // Nice message with the same precision the comparison used + let a_round = a_round_i as f64 / scale; + let e_round = e_round_i as f64 / scale; + + let msg = format!( + "expected ~= {e:.prec$} but got {a:.prec$} (rounded to {places} dp; {e_round:.prec$} vs {a_round:.prec$})", + e = e_f64, + a = a_f64, + e_round = e_round, + a_round = a_round, + prec = places_u, + places = places_u + ); + $obs.fail(&msg); + } + }}; +} + +/// Syntactic sugar to add inline tests: +/// add_test!(reporter, "Name", |obs| { /* ... */ }); +#[macro_export] +macro_rules! add_test { + ($reporter:expr, $name:expr, |$obs:ident| $body:block) => {{ + $reporter.add_inline($name, move |$obs: &mut TestObserver| $body); + }}; +} +``` +This establishes the feature framework we discussed above. It is able to collect and report on test evaluations for one or many test sections, and provides some helpful macros such as `add_test!` to register a closure as the evaluation function as well as some assertion macros designed to use with the `TestObserver`. + +Add this as a module also to `main.rs`: +```rust +mod test_reporter; +``` + +## Wiring it into the IntegrationTest DisplayRenderer +Central to our plan is the idea that we can make a `DisplayRenderer` variant that will feed us the results of the simulation as it runs. We can then evaluate these values in context and assign Pass/Fail results to the `TestReporter` and print out the final tally. + +To do this, we need to "tap" the `render_static` and `render_frame` traits of our `IntegrationTestBackend` and feed this data into where we are running the test code. + +### Adding the TestTap + +Let's replace our placeholder `integration_test_render.rs` file with this new version: +```rust + +use crate::display_render::display_render::{RendererBackend}; +use crate::display_models::{StaticValues,DisplayValues, InteractionValues}; +use ec_common::mutex::{Mutex, RawMutex}; + +pub trait TestTap: Send + 'static { + fn on_static(&mut self, sv: &StaticValues); + fn on_frame(&mut self, dv: &DisplayValues); +} + +struct NullTap; +#[allow(unused)] +impl TestTap for NullTap { + fn on_static(&mut self, _sv: &StaticValues) {} + fn on_frame(&mut self, _dv: &DisplayValues) {} +} + +pub struct IntegrationTestBackend { + tap: Mutex> +} +impl IntegrationTestBackend { + pub fn new() -> Self { + Self { + tap: Mutex::new(Box::new(NullTap)) + } + } +} +impl RendererBackend for IntegrationTestBackend { + fn render_frame(&mut self, dv: &DisplayValues, _ia: &InteractionValues) { + let mut t = self.tap.try_lock().expect("tap locked in another task?"); + t.on_frame(dv); + } + fn render_static(&mut self, sv: &StaticValues) { + let mut t = self.tap.try_lock().expect("tap locked in another task?"); + t.on_static(sv); + } + #[cfg(feature = "integration-test")] + fn set_test_tap(&mut self, tap: Box) { + let mut guard = self.tap.try_lock().expect("tap locked in another task?"); + *guard = tap; + }} +``` + +You will see that we have defined a `TestTap` trait that provides us with the callback methods we are looking for to feed our test running code. We've given a concrete implementation `NullTap` to use as a no-op stub to hold fort until we replace it with `set_test_tap()` later. + +We will need to make some changes to our `display_render.rs` file to accommodate this. Open up that file and add the following: + +```rust +use crate::display_render::integration_test_render::IntegrationTestBackend; +#[cfg(feature = "integration-test")] +use crate::display_render::integration_test_render::TestTap; + +``` + +Change the trait definition for `RendererBackend` to now be: +```rust +// Define a trait for the interface for a rendering backend +pub trait RendererBackend : Send + Sync { + fn on_enter(&mut self, _last: Option<&DisplayValues>) {} + fn on_exit(&mut self) {} + fn render_frame(&mut self, dv: &DisplayValues, ia: &InteractionValues); + fn render_static(&mut self, sv: &StaticValues); + #[cfg(feature = "integration-test")] + fn set_test_tap(&mut self, _tap: Box) {} +} +``` + +This gives us the ability to set the test tap, and it defaults to nothing unless we implement it, as we have done already in `integration_test_render.rs`. + +Now add this function to the `impl DisplayRender` block: +```rust + #[cfg(feature = "integration-test")] + pub fn set_test_tap(&mut self, tap: T) -> Result<(), &'static str> + where + T: TestTap + Send + 'static, + { + if self.mode != RenderMode::IntegrationTest { + return Err("Renderer is not in Integration Test mode"); + } + self.backend.set_test_tap(Box::new(tap)); + Ok(()) + } +``` + +### Using these changes in test code + +Now we can start to build in the test code itself. + +We'll create a new file for this: `integration_test.rs` and give it this content: +```rust +#[cfg(feature = "integration-test")] +pub mod test_module { + + use crate::test_reporter::test_reporter::TestObserver; + use crate::test_reporter::test_reporter::TestReporter; + use crate::{add_test,expect, expect_eq}; + use crate::display_models::{DisplayValues, StaticValues}; + use crate::display_render::integration_test_render::TestTap; + use crate::entry::DisplayChannelWrapper; + use crate::display_render::display_render::DisplayRenderer; + use crate::events::RenderMode; + + #[embassy_executor::task] + pub async fn integration_test(rx: &'static DisplayChannelWrapper) { + + let mut reporter = TestReporter::new(); + + reporter.start_test_section(); + + + struct ITest { + reporter: TestReporter, + first_time: Option, + test_time_ms: u64, + saw_static: bool, + frame_count: i16 + } + impl ITest { + pub fn new() -> Self { + Self { + reporter: TestReporter::new(), + first_time: None, + test_time_ms: 0, + saw_static: false, + frame_count: 0 + } + } + } + impl TestTap for ITest { + fn on_static(&mut self, sv: &StaticValues) { + add_test!(self.reporter, "Static Values received", |obs| { + obs.pass(); + }); + self.saw_static = true; + println!("🔬 Integration testing starting..."); + } + fn on_frame(&mut self, dv: &DisplayValues) { + let load_ma= dv.load_ma; + let first = self.first_time.get_or_insert(dv.sim_time_ms as u64); + self.test_time_ms = (dv.sim_time_ms as u64).saturating_sub(*first); + + if self.frame_count == 0 { + // ⬇️ Take snapshots so the closure doesn't capture `self` + let saw_static_snapshot = self.saw_static; + let load_at_start = load_ma; + let expected = 1200; + + add_test!(self.reporter, "First Test Data Frame received", |obs| { + expect!(obs, saw_static_snapshot, "Static Data should have come first"); + expect_eq!(obs, load_at_start, expected, "Load value at start"); + obs.pass(); + }); + } + + self.frame_count += 1; + + if self.test_time_ms > 5_000 { + // `self` is fine to use here; the borrow from add_test! ended at the call. + self.reporter.evaluate_tests(); + self.reporter.print_report(); + std::process::exit(0); + } + } + } + let mut r = DisplayRenderer::new(RenderMode::IntegrationTest); + r.set_test_tap(ITest::new()).unwrap(); + r.run(rx).await; + + } +} +``` +Note that we've wrapped this entire file content as a module and gated it behind `#[cfg(feature = "integration-test")]` so that it is only valid in integration-test mode. + +add this module to `main.rs` +```rust +mod integration_test; +``` + +and in `entry.rs`, add the import for this task: +```rust +#[cfg(feature = "integration-test")] +use crate::integration_test::test_module::integration_test; +``` + +also in `entry.rs`, replace the spawn of `render_task` with the spawn to `integration_test`, passing the display channel that will continue to be used for our tapped Display messaging which will now route to our test code. +```rust +#[cfg(feature = "integration-test")] +#[embassy_executor::task] +pub async fn entry_task_integration_test(spawner: Spawner) { + println!("🚀 Integration test mode: integration project"); + let shared = init_shared(); + + println!("setup_and_tap_starting"); + let battery_ready = shared.battery_ready; + spawner.spawn(setup_and_tap_task(spawner, shared)).unwrap(); + battery_ready.wait().await; + println!("init complete"); + + spawner.spawn(integration_test(shared.display_channel)).unwrap(); +} +``` + +A `cargo run --features integration-test` should produce the following output: +``` + Running `C:\Users\StevenOhmert\odp\ec_examples\target\debug\integration_project.exe` +🚀 Integration test mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start() +spawning controller_core_task +spawning start_charger_task +spawning charger_policy_event_task +spawning integration_listener_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +battery-service DoInit -> Ok(Ack) +🔬 Integration testing starting... +================================================== + Test Section Report +[PASS] Static Values received (1µs) +[PASS] First Test Data Frame received (400ns) + + Summary: total=2, passed=2, failed=0 +================================================== +``` + +That's a good proof-of-concept start. Let's create some meaningful tests now. + + + + + + + diff --git a/guide_book/src/how/ec/integration/19-meaningful_tests.md b/guide_book/src/how/ec/integration/19-meaningful_tests.md new file mode 100644 index 0000000..215d67c --- /dev/null +++ b/guide_book/src/how/ec/integration/19-meaningful_tests.md @@ -0,0 +1,253 @@ +# Adding Meaningful Tests + +We have the ability to run the app as an interactive simulation, including the logging mode that will output a running record of the changes over time as we change the load. + +So, it makes sense to derive some sense of expected behavior from these results and model tests that correspond to this. + +## What are we really testing? + +Of course, this is a simulated integration of virtual components -- running simulation algorithms as stand-ins for actual physical behaviors -- so when we run our tests, we are also testing the realism of these sims. Although reasonable effort has been made to account for the physics of temperature change, battery life, and so on, it should not be expected that these are precisely accurate. In a real integration, you don't get to change the effects of physics -- so we'll test against the physical reality as it is presented to us, realistic or otherwise. + +Running with the default configurations as we have built them in this example, we can observe the battery starts off at 100% SOC and we have a starting default system load/draw of 9.4W. The battery thus discharges to a point where the charger activates below 90%, then charges back up to 100%, detaches the charger, and the cycle repeats. + +If we increase the load during any of this, the battery discharges faster, and the temperature rises more quickly and at the configured point of 28 degrees celsius, the fan turns on to facilitate cooling. The ability of the fan to counter the load depends upon the continued draw level, and whether or not the charger is running. If cooling is sufficient, the fan slows, and under lower load, will turn off. + +As we've written it, the test context does not have the ability to change the simulated time multiplier the way the interactive context allows, so all simulation time for the test runs at the pre-configured level 3 (25X). + +### Running faster +Since this is a test, we don't need to dally. Let's make a change so that the default level for the integration-test mode is level 5 (100X). In `controller_core.rs` add the following lines below the temperature threshold settings within the set-aside mutex lock block: +```rust + + #[cfg(feature = "integration-test")] + core.sysobs.set_speed_number(5).await; +``` + +## Checking static, then stepwise events + +Our initial tests already establish that static data is received, and verifies the one-time-at-the-start behavior is respected, but we don't check any values. This is largely superfluous, of course, but we should verify anyway. + +Following this first event, we need a good way to know where we are at in the flow of subsequent events so that we can properly evaluate and direct the context at the time. + +Let's update our current `integration_test.rs` code with a somewhat revised version: +```rust +#[cfg(feature = "integration-test")] +pub mod integration_test { + use crate::test_reporter::test_reporter::TestObserver; + use crate::test_reporter::test_reporter::TestReporter; + use crate::{add_test,expect, expect_eq}; + use crate::display_models::{DisplayValues, StaticValues}; + use crate::display_render::integration_test_render::TestTap; + use crate::entry::DisplayChannelWrapper; + use crate::display_render::display_render::DisplayRenderer; + use crate::events::RenderMode; + + #[allow(unused)] + #[derive(Debug)] + enum TestStep { + None, + CheckStartingValues, + CheckChargerAttach, + RaiseLoadAndCheckTemp, + RaiseLoadAndCheckFan, + LowerLoadAndCheckCooling, + EndAndReport + } + + #[embassy_executor::task] + pub async fn integration_test(rx: &'static DisplayChannelWrapper) { + + struct ITest { + reporter: TestReporter, + first_time: Option, + test_time_ms: u64, + saw_static: bool, + frame_count: i16, + step: TestStep, + } + impl ITest { + pub fn new() -> Self { + let mut reporter = TestReporter::new(); + reporter.start_test_section(); // start out with a new section + Self { + reporter, + first_time: None, + test_time_ms: 0, + saw_static: false, + frame_count: 0, + step: TestStep::None + } + } + + // -- Individual step tests --- + + fn check_starting_values(&mut self, draw_watts:f32) -> TestStep { + let reporter = &mut self.reporter; + add_test!(reporter, "Check Starting Values", |obs| { + expect_eq!(obs, draw_watts, 9.4); + obs.pass(); + }); + TestStep::EndAndReport + } + + // --- final step to report and exit -- + fn end_and_report(&mut self) { + let reporter = &mut self.reporter; + reporter.evaluate_tests(); + reporter.end_test_section(); + std::process::exit(0); + } + } + impl TestTap for ITest { + fn on_static(&mut self, sv: &StaticValues) { + let _ = sv; + add_test!(self.reporter, "Static Values received", |obs| { + obs.pass(); + }); + self.saw_static = true; + println!("🔬 Integration testing starting..."); + } + fn on_frame(&mut self, dv: &DisplayValues) { + + let reporter = &mut self.reporter; + let first = self.first_time.get_or_insert(dv.sim_time_ms as u64); + self.test_time_ms = (dv.sim_time_ms as u64).saturating_sub(*first); + + if self.frame_count == 0 { + // Take snapshots so the closure doesn't capture `self` + let saw_static = self.saw_static; + + add_test!(reporter, "First Test Data Frame received", |obs| { + expect!(obs, saw_static, "Static Data should have come first"); + obs.pass(); + }); + self.step = TestStep::CheckStartingValues; + } + println!("Step {:?}", self.step); + match self.step { + TestStep::CheckStartingValues => { + let draw_watts = dv.draw_watts; + self.step = self.check_starting_values(draw_watts); + }, + TestStep::EndAndReport => self.end_and_report(), + _ => {} + } + + self.frame_count += 1; + + } + } + let mut r = DisplayRenderer::new(RenderMode::IntegrationTest); + r.set_test_tap(ITest::new()).unwrap(); + r.run(rx).await; + + } +} +``` +This introduces a few notable changes. + +We've introduced an enum, `TestStep`, that names a series of proposed points in the flow that we wish to make measurements. For now, we are only using the first of these `CheckStartingValues`, but the pattern will remain the same for any subsequent steps. We have a corresponding `check_starting_values` method defined that conducts the actual test. Note the `end_and_report` method also, which is the last step of the flow and signals it is time to report the test results and exit. + +This revised version does little more just yet than our previous one, but it sets the stage for stepwise updates. +`cargo run --features integration-test`: +``` +🚀 Integration test mode: integration project +setup_and_tap_starting +⚙️ Initializing embedded-services +⚙️ Spawning battery service task +⚙️ Spawning battery wrapper task +🧩 Registering battery device... +🧩 Registering charger device... +🧩 Registering sensor device... +🧩 Registering fan device... +🔌 Initializing battery fuel gauge service... +Setup and Tap calling ControllerCore::start... +In ControllerCore::start() +spawning controller_core_task +spawning start_charger_task +spawning charger_policy_event_task +spawning integration_listener_task +init complete +🥺 Doing battery service startup -- DoInit followed by PollDynamicData +✅ Charger is ready. +🥳 >>>>> ping has been called!!! <<<<<< +🛠️ Charger initialized. +battery-service DoInit -> Ok(Ack) +🔬 Integration testing starting... +Step CheckStartingValues +Step EndAndReport +================================================== + Test Section Report +[PASS] Static Values received (700ns) +[PASS] First Test Data Frame received (400ns) +[PASS] Check Starting Values (300ns) + + Summary: total=3, passed=3, failed=0 +================================================== +``` +Before we move on with the next steps, let's finish out the perfunctory tasks of verifying our static data and a couple more starting values: + +Replace the current `on_static` method with this one: +```rust + fn on_static(&mut self, sv: &StaticValues) { + let reporter = &mut self.reporter; + let mfg_name = sv.battery_mfr.clone(); + let dev_name = sv.battery_name.clone(); + let chem = sv.battery_chem.clone(); + let cap_mwh = sv.battery_dsgn_cap_mwh; + let cap_mv = sv.battery_dsgn_voltage_mv; + add_test!(reporter, "Static Values received", |obs| { + expect_eq!(obs, mfg_name.trim_end_matches('\0'), "MockBatteryCorp"); + expect_eq!(obs, dev_name.trim_end_matches('\0'), "MB-4200"); + expect_eq!(obs, chem.trim_end_matches('\0'), "LION"); + expect_eq!(obs, cap_mwh, 5000); + expect_eq!(obs, cap_mv, 7800); + }); + self.saw_static = true; + println!("🔬 Integration testing starting..."); + } +``` +and we'll check some more of the starting values. Change the member function `check_starting_values()` to this version: +```rust + fn check_starting_values(&mut self, soc:f32, draw_watts:f32, charge_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + let reporter = &mut self.reporter; + add_test!(reporter, "Check Starting Values", |obs| { + expect_eq!(obs, soc, 100.0); + expect_eq!(obs, draw_watts, 9.4); + expect_eq!(obs, charge_watts, 0.0); + expect_to_decimal!(obs, temp_c, 24.6, 1); + expect_eq!(obs, fan_level, 0); + }); + TestStep::EndAndReport + } +``` +and change the match arm to call it like this: +```rust + TestStep::CheckStartingValues => { + let draw_watts = dv.draw_watts; + let charge_watts = dv.charge_watts; + let temp_c = dv.temp_c; + let soc = dv.soc_percent; + let fan_level = dv.fan_level; + + self.step = self.check_starting_values(soc, draw_watts, charge_watts, temp_c, fan_level); + }, +``` +Now we can be reasonably confident that we are starting out as expected before continuing. + + +> ### A note on test value measurements +> In our starting values test we check the starting temperature pretty closely (to within 1 decimal position), +> but in other tests we look for a more general threshold of range. +> In the case of starting values, we know what this should be because it comes from the scenario configurations, and +> we can be confident in the deterministic outcome. +> In other examples, we can't be entirely sure of the vagaries of time -- even in a simulation, what with differing host computer speeds, drifts in clocks, and inevitable inaccuracies in our simulated physics. So we "loosen the belt" a bit more in these situations. +> +> ---- + + + + + + + + diff --git a/guide_book/src/how/ec/integration/2-move_events.md b/guide_book/src/how/ec/integration/2-move_events.md new file mode 100644 index 0000000..9be6bce --- /dev/null +++ b/guide_book/src/how/ec/integration/2-move_events.md @@ -0,0 +1,675 @@ +# Configurable knobs and controls +We have multiple components, each with different settings, and which interact with one another through definable rules. Defining these adjustable values now helps us to better visualize the infrastructure needed to support their usage and interrelationships. + +## Creating project structure +In your `integration_project` directory, create a `src` directory. +Then create a `main.rs` file within `src`. Leave it empty for now. We'll come to that shortly. +Then create the following folders within `src` that we will use for our collection of configurable settings: +- `config` - This will hold our general configuration knobs and switches in various categories +- `model` - This will hold our behavioral models, specifically our thermal behavior. +- `policy` - This will hold the decision-making policies for the charger and thermal components. +- `state` - This tracks various aspects of component state along the way. + +### The config files +Add the following files within the `src/config` folder: + +`config/policy_config.rs`: +```rust + +use mock_thermal::mock_fan_controller::FanPolicy; + + +#[derive(Clone)] +/// Parameters for the charger *policy* (attach/detach + current/voltage requests). +/// - Attach/Detach uses SOC hysteresis + idle gating (time since last heavy load). +/// - Current requests combine a SOC-taper target, a power-deficit boost, slew limiting, +/// and small-change hysteresis to avoid chatter. +pub struct ChargerPolicyCfg { + /// Instantaneous discharge current (mA, positive = drawing from battery) that qualifies + /// as “heavy use.” When load_ma >= heavy_load_ma, update `last_heavy_load_ms = now_ms`. + pub heavy_load_ma: i32, + + /// Required idle time (ms) since the last heavy-load moment before we may **detach**. + /// Implemented as: `since_heavy >= idle_min_ms`. + pub idle_min_ms: u64, + + /// Minimum time (ms) since the last attach/detach change before we may **(re)attach**. + /// Anti-chatter dwell for entering the attached state. + pub attach_dwell_ms: u64, + + /// Minimum time (ms) since the last attach/detach change before we may **detach**. + /// Anti-chatter dwell for leaving the attached state. + pub detach_dwell_ms: u64, + + /// If `SOC <= attach_soc_max`, we *want to attach* (low side of hysteresis). + pub attach_soc_max: u8, + + /// If `SOC >= detach_soc_min` **and** we’ve been idle long enough, we *want to detach* + /// (high side of hysteresis). Keep `detach_soc_min > attach_soc_max`. + pub detach_soc_min: u8, + + /// Minimum spacing (ms) between policy actions (e.g., recomputing/sending a new capability). + /// Acts like a control-loop cadence limiter. + pub policy_min_interval_ms: u64, + + /// Target voltage (mV) to use while in **CC (constant current)** charging (lower SOC). + /// Typically below `v_float_mv`. + pub v_cc_mv: u16, + + /// Target voltage (mV) for **CV/float** region (higher SOC). Used after CC phase. + pub v_float_mv: u16, + + /// Upper bound (mA) on requested charge current from policy (device caps may further clamp). + pub i_max_ma: u16, + + /// Proportional gain mapping **power deficit** (Watts) → **extra charge current** (mA), + /// to cover system load while attached: `p_boost_ma = clamp(kp_ma_per_w * watts_deficit)`. + pub kp_ma_per_w: f32, + + /// Hard cap (mA) on the proportional “power-boost” term so large deficits don’t overshoot. + pub p_boost_cap_ma: u16, + + /// Maximum rate of change (mA/s) applied by the setpoint slewer. Prevents step jumps + /// and improves stability/realism. + pub max_slew_ma_per_s: u16, + + /// Minimum delta (mA) between current setpoint and new target before updating. + /// If `|target - current| < policy_hysteresis_ma`, do nothing (reduces twitch). + pub policy_hysteresis_ma: u16, +} + +impl Default for ChargerPolicyCfg { + fn default() -> Self { + Self { + heavy_load_ma: 800, idle_min_ms: 3000, attach_dwell_ms: 3000, detach_dwell_ms:3000, + attach_soc_max: 90, detach_soc_min: 95, + + policy_min_interval_ms: 3000, + v_cc_mv: 8300, + v_float_mv: 8400, + + i_max_ma: 4500, + kp_ma_per_w: 50.0, p_boost_cap_ma: 800, + max_slew_ma_per_s: 4000, policy_hysteresis_ma: 50 + } + } +} + +#[derive(Clone)] +/// Parameters for the *policy* (how we choose a fan level based on temperature). +pub struct ThermalPolicyCfg { + /// Lower temperature (°C) where cooling begins (or where we allow stepping *down*). + /// Often the bottom of your control band. Keep < temp_high_on_c. + pub temp_low_on_c: f32, + + /// Upper temperature (°C) where stronger cooling is demanded (or step *up*). + /// Often the top of your control band. Keep > temp_low_on_c. + pub temp_high_on_c: f32, + + /// Fan hysteresis in °C applied around thresholds to prevent chatter. + /// Example: step up when T > (threshold + fan_hyst_c); step down when T < (threshold - fan_hyst_c). + pub fan_hyst_c: f32, + + /// Minimum time (ms) the fan must remain at the current level before another change. + /// Anti-flap dwell; choose ≥ your control loop interval and long enough to feel stable. + pub fan_min_dwell_ms: u64, + + /// The mapping/strategy for levels (e.g., L0..L3) → duty cycle (%), plus any custom rules. + /// Typically defines the % per level and possibly per-level entry/exit thresholds. + pub fan_policy: FanPolicy, +} + +impl Default for ThermalPolicyCfg { + fn default() -> Self { + Self { + temp_low_on_c: 22.0, + temp_high_on_c: 30.0, + fan_hyst_c: 1.5, + fan_min_dwell_ms: 1000, + fan_policy: FanPolicy::default() + } + } +} + +#[derive(Clone, Default)] +/// Combined settings that affect policy +pub struct PolicyConfig { + pub charger: ChargerPolicyCfg, + pub thermal: ThermalPolicyCfg, +} +``` + +`config/sim_config.rs`: +```rust +// src/config/sim_config.rs +#[allow(unused)] +#[derive(Clone)] +/// Parameters for the simple thermal *model* (how temperature evolves). +/// Roughly: T' = (T_ambient - T)/tau_s + k_load_w * P_watts - k_fan_pct * fan_pct +pub struct ThermalModelCfg { + /// Ambient/environment temperature in °C (the asymptote with zero load & zero fan). + pub ambient_c: f32, + + /// Thermal time constant (seconds). Larger = slower temperature response. + /// Used to low-pass (integrate) toward ambient + heat inputs. + pub tau_s: f32, + + /// Heating gain per electrical power (°C/sec per Watt), or folded into your integrator + /// as °C per tick when multiplied by P_w and dt. Higher => load heats the system faster. + /// Typical: start small (e.g., 0.001–0.02 in your dt units) and tune until ramps look plausible. + pub k_load_w: f32, + + /// Cooling gain per fan percentage (°C/sec per 100% fan), or °C per tick when multiplied + /// by (fan_pct/100) and dt. Higher => fan cools more aggressively. + /// Tune so “100% fan” can arrest/ramp down temp under expected max load. + pub k_fan_pct: f32, + + /// Nominal battery/system voltage in mV (for converting current → power when needed). + /// Example: P_w ≈ (load_ma * v_nominal_mv) / 1_000_000. Use an average system/battery voltage. + pub v_nominal_mv: u16, + + /// Maximum discrete fan level your policy supports (e.g., 3 → L0..L3). + /// Used for clamping and for mapping policy levels to a %. + pub max_fan_level: u8, + + /// fractional heat contributions of charger/charging power + /// Rough guide: 5–10% PSU loss + /// °C per Watt of charge power + pub k_psu_loss: f32, + + /// fractional heat contributions of charger/charging power + /// Rough guide: a few % battery heating during charge. + /// °C per Watt of charge power + pub k_batt_chg: f32, +} + + +#[allow(unused)] +#[derive(Clone)] +/// settings applied to the simulator behavior itself +pub struct TimeSimCfg { + /// controls the speed of the simulator -- a multiple of simulated seconds per 1 real-time second. + pub sim_multiplier: f32, +} + +#[allow(unused)] +#[derive(Clone)] +/// parameters that define the capabilities of the integrated charging system +pub struct DeviceCaps { + /// maximum current (mA) of device + pub max_current_ma: u16, // 3000 for mock + /// maximum voltage (mV) of device + pub max_voltage_mv: u16, // 15000 for mock +} + +#[allow(unused)] +#[derive(Clone)] +/// Combined settings that affect the simulation behavior. +pub struct SimConfig { + pub time: TimeSimCfg, + pub thermal: ThermalModelCfg, + pub device_caps: DeviceCaps, +} + +impl Default for SimConfig { + fn default() -> Self { + Self { + time: TimeSimCfg { sim_multiplier: 25.0 }, + thermal: ThermalModelCfg { + ambient_c: 23.0, tau_s: 4.0, k_load_w: 0.20, k_fan_pct: 0.015, + v_nominal_mv: 8300, max_fan_level: 10, + }, + device_caps: DeviceCaps { max_current_ma: 4800, max_voltage_mv: 15000 }, + } + } +} +``` + +`config/ui_config.rs`: +```rust + +#[derive(Clone, PartialEq)] +#[allow(unused)] +/// Defines which types of rendering we can choose from +/// `InPlace` uses ANSI-terminal positioning for a static position display +/// `Log` uses simple console output, useful for tracking record over time. +pub enum RenderMode { InPlace, Log } + +#[derive(Clone)] +#[allow(unused)] +/// Combined UI settings +pub struct UIConfig { + /// InPlace or Log + pub render_mode: RenderMode, + /// Initial load (mA) to apply prior to any interaction + pub initial_load_ma: u16, +} +impl Default for UIConfig { + fn default() -> Self { + Self { render_mode: RenderMode::InPlace, initial_load_ma: 1200 } + } +} +``` +And we need a `mod.rs` file within the folder to bring these together for inclusion: + +`config/mod.rs`: +```rust +// config +pub mod sim_config; +pub mod policy_config; +pub mod ui_config; + +pub use sim_config::SimConfig; +pub use policy_config::PolicyConfig; +pub use ui_config::UIConfig; + +#[derive(Clone, Default)] +pub struct AllConfig { + pub sim: SimConfig, + pub policy: PolicyConfig, + pub ui: UIConfig, +} +``` +A quick scan of these values shows that these represent various values one may want to adjust in order to model different component capabilities, behaviors, or conditions. The final aggregate, `AllConfig` brings all of these together into one nested structure. The `Default` implementations for each simplify the normal setting of these values at construction time. These values can be adjusted to suit your preferences. If you are so inclined, you might even consider importing these values from a configuration file, but we won't be doing that here. + +Now let's continue this pattern for the `policy`, `state`, and `model` categories as well. +`policy/charger_policy.rs`: +```rust +use embedded_services::power::policy::PowerCapability; +use crate::config::policy_config::ChargerPolicyCfg; +use crate::config::sim_config::DeviceCaps; + +pub fn derive_target_ma(cfg: &ChargerPolicyCfg, dev: &DeviceCaps, soc: u8, load_ma: i32) -> u16 { + let i_max = cfg.i_max_ma.min(dev.max_current_ma); + let cover_load = (load_ma + cfg.heavy_load_ma as i32).max(0) as u16; + + // piecewise taper + let soc_target = if soc < 60 { i_max } + else if soc < 85 { (i_max as f32 * 0.80) as u16 } + else if soc < cfg.attach_soc_max { (i_max as f32 * 0.60) as u16 } + else if soc < 97 { (i_max as f32 * 0.35) as u16 } + else { (i_max as f32 * 0.15) as u16 }; + + cover_load.max(soc_target).min(i_max) +} + +pub fn p_boost_ma(kp_ma_per_w: f32, p_boost_cap_ma: u16, watts_deficit: f32) -> u16 { + (kp_ma_per_w * watts_deficit.max(0.0)).min(p_boost_cap_ma as f32) as u16 +} + +pub fn slew_toward(current: u16, target: u16, dt_s: f32, rate_ma_per_s: u16) -> u16 { + let max_delta = (rate_ma_per_s as f32 * dt_s) as i32; + let delta = target as i32 - current as i32; + if delta.abs() <= max_delta { target } + else if delta > 0 { (current as i32 + max_delta) as u16 } + else { (current as i32 - max_delta) as u16 } +} + +pub fn build_capability(cfg: &ChargerPolicyCfg, dev: &DeviceCaps, soc: u8, current_ma: u16) -> PowerCapability { + let v_target = if soc < cfg.attach_soc_max { cfg.v_cc_mv } else { cfg.v_float_mv }; + PowerCapability { + voltage_mv: v_target.min(dev.max_voltage_mv), + current_ma: current_ma.min(dev.max_current_ma), + } +} + +pub struct AttachDecision { + pub attach: bool, // true=attach, false=detach + pub do_change: bool, +} + +#[inline] +fn dwell_ok(was_attached: bool, since_change_ms: u64, cfg: &ChargerPolicyCfg) -> bool { + if was_attached { + since_change_ms >= cfg.detach_dwell_ms + } else { + since_change_ms >= cfg.attach_dwell_ms + } +} + +#[inline] +fn ms_since(t:u64, now:u64) -> u64 { + now - t +} + +pub fn decide_attach( + cfg: &ChargerPolicyCfg, + was_attached: bool, + soc: u8, // 0..=100 + last_psu_change_ms: u64, // when we last toggled attach/detach + last_heavy_load_ms: u64, // when we last saw heavy load + now_ms: u64, +) -> AttachDecision { + let since_change = ms_since(last_psu_change_ms, now_ms); + let since_heavy = ms_since(last_heavy_load_ms, now_ms); + + // Hysteresis-based targets + let want_attach = soc <= cfg.attach_soc_max; + let want_detach = (soc >= 100 && was_attached) || (soc >= cfg.detach_soc_min && since_heavy >= cfg.idle_min_ms); + + let can_change = dwell_ok(was_attached, since_change, cfg); + + // Priority rules: + // 1) If we are attached and conditions say detach, and dwell is satisfied -> detach. + // 2) If we are detached and conditions say attach, and dwell is satisfied -> attach. + // 3) Otherwise no-op. + if was_attached { + if want_detach && can_change { + return AttachDecision { attach: false, do_change: true }; + } + } else { + if want_attach && can_change { + return AttachDecision { attach: true, do_change: true }; + } + } + + AttachDecision { attach: was_attached, do_change: false } +} +``` + +`policy/thermal_governor.rs`: +```rust + +use ec_common::events::{CoolingRequest, ThresholdEvent}; +use crate::state::ThermalState; + +pub struct ThermDecision { + pub threshold_event: ThresholdEvent, + pub cooling_request: Option, + pub hi_latched: bool, + pub lo_latched: bool, + pub did_step: bool, +} + +pub fn process_sample( + temp_c: f32, + hi_latched: bool, lo_latched: bool, + hi_on: f32, lo_on: f32, + hyst: f32, + last_fan_change_ms: u64, + dwell_ms: u64, + now_ms: u64, + mut state: ThermalState +) -> ThermDecision { + let hi_off = hi_on - hyst; + let lo_off = lo_on + hyst; + + let mut hi = hi_latched; + let mut lo = lo_latched; + if !hi && temp_c >= hi_on { hi = true; } + if hi && temp_c <= hi_off { hi = false; } + if !lo && temp_c <= lo_on { lo = true; } + if lo && temp_c >= lo_off { lo = false; } + + // Compute a *non-overlapping* neutral zone around the midpoint + let mid = 0.5 * (hi_on + lo_on); + let center_hyst = 0.5 * hyst; // or a new cfg.center_hyst_c if you want it independent + + let dwell_ok = now_ms - last_fan_change_ms >= dwell_ms; + + let want_dir: i8 = + if hi { 1 } + else if lo { -1 } + else if temp_c > mid + center_hyst { 1 } + else if temp_c < mid - center_hyst { -1 } + else { 0 }; + + let dir_dwell_ms = dwell_ms / 2; // or a separate cfg.fan_dir_dwell_ms + let reversing = want_dir != 0 && want_dir == -state.last_dir; + let dir_ok = !reversing || (now_ms - state.last_dir_change_ms >= dir_dwell_ms); + + let mut threshold_event = ThresholdEvent::None; + let mut cooling_request = None; + let mut did_step = false; + + if dwell_ok && dir_ok { + if want_dir > 0 { cooling_request = Some(CoolingRequest::Increase); did_step = true; } + if want_dir < 0 { cooling_request = Some(CoolingRequest::Decrease); did_step = true; } + } + + if did_step { + state.last_fan_change_ms = now_ms; + if want_dir != 0 && want_dir != state.last_dir { + state.last_dir = want_dir; + state.last_dir_change_ms = now_ms; + } + } + + + // Edge-generated events (for logging/telemetry), but not the only time we step + if hi && !hi_latched { + threshold_event = ThresholdEvent::OverHigh; + } else if lo && !lo_latched { + threshold_event = ThresholdEvent::UnderLow; + } + + ThermDecision { threshold_event, cooling_request, hi_latched: hi, lo_latched: lo, did_step } +} +``` + +`policy/mod.rs`: +```rust +//policy +pub mod charger_policy; +pub mod thermal_governor; +``` +These policy implementations define functions that control the decision rules for the charger and thermal components. + +`state/charger_state.rs`: +```rust + +pub struct ChargerState { + pub requested_ma: u16, + pub last_policy_sent_at_ms: u64, + pub was_attached: bool, + pub last_psu_change_ms: u64, + pub last_heavy_load_ms: u64, +} + +impl Default for ChargerState { + fn default() -> Self { + Self { + requested_ma: 0, + last_policy_sent_at_ms: 0, + was_attached: false, + last_psu_change_ms: 0, + last_heavy_load_ms: 0, + } + } +} +``` + +`state/thermal_state.rs`: +```rust + +#[derive(Copy, Clone)] +pub struct ThermalState { + pub fan_level: u8, + pub hi_latched: bool, + pub lo_latched: bool, + pub last_fan_change_ms: u64, + pub last_dir: i8, + pub last_dir_change_ms: u64 +} + +impl Default for ThermalState { + fn default() -> Self { + Self { + fan_level: 0, + hi_latched: false, + lo_latched: false, + last_fan_change_ms: 0, + last_dir: 0, + last_dir_change_ms: 0 + } + } +} + +``` + +`state/sim_state.rs`: +```rust +use embassy_time::Instant; + +pub struct SimState { + pub last_update: Instant, +} + +impl Default for SimState { + fn default() -> Self { + Self { last_update: Instant::now() } + } +} +``` + +`state/mod.rs`: +```rust +// state +pub mod charger_state; +pub mod thermal_state; +pub mod sim_state; + +pub use charger_state::ChargerState; +pub use thermal_state::ThermalState; +pub use sim_state::SimState; +``` +These states are used to track the current condition of the simulation and its components in action over time. + +`model/thermal_model.rs`: +```rust +use crate::config::sim_config::ThermalModelCfg; + +pub fn step_temperature( + t: f32, + load_ma: i32, + fan_level: u8, + cfg: &ThermalModelCfg, + dt_s: f32, + chg_w: f32, // NEW: charge power in Watts (0 if not charging) +) -> f32 { + let load_w = (load_ma.max(0) as f32) * (cfg.v_nominal_mv as f32) / 1_000_000.0; + + // Fractional heat contributions + let psu_heat_w = cfg.k_psu_loss * chg_w; // DC-DC inefficiency + board losses + let batt_heat_w = cfg.k_batt_chg * chg_w; // battery internal resistance during charge + + let fan_pct = 100.0 * (fan_level as f32) / (cfg.max_fan_level as f32).max(1.0); + + // Combined drive: ambient + load heat + charger/battery heat - fan cooling + let drive = cfg.ambient_c + + cfg.k_load_w * load_w + + psu_heat_w + + batt_heat_w + - cfg.k_fan_pct * fan_pct; + + let alpha = (dt_s / cfg.tau_s).clamp(0.0, 1.0); + (t + alpha * (drive - t)).max(cfg.ambient_c) +} +``` +`model/mod.rs`: +```rust +// model +pub mod thermal_model; +``` +The thermal model is used to express the physical effects of the cooling airflow from the fan. You will recall that the physical effects of the virtual battery have already been implemented via the `tick()` method of `VirtualBattery`, which also computes a temperature generated by the battery itself. This thermal model complements this in this integrated simulation by applying a cooling effect function. + + + +## Consolidating events +Earlier we mentioned that we would simplify our comms implementation in this exercise by consolidating the message types onto a single communication channel bus. + +> ### Why one bus? +> - easier tracing +> - simpler buffering +> - less churn when adding new message types +> ---- + +Let's define a single enum to help us with that now: + +Create `events.rs` with this content: +```rust +use embedded_services::power::policy::charger::ChargerEvent; +use ec_common::events::ThermalEvent; +use embedded_services::power::policy::PowerCapability; + +#[allow(unused)] +#[derive(Debug)] +pub enum BusEvent { + Charger(ChargerEvent), + ChargerPolicy(PowerCapability), // associates with PolicyEvent::PowerConfiguration for our handling + Thermal(ThermalEvent), +} +``` +Notice in this code it refers to `ec_common::events::ThermalEvent` but we don't have our `ThermalEvent` in `ec_common`. We had defined that as part of our `thermal_project` exercise, but did not add it to the `ec_common` `events.rs` file. We can copy the definition from there and add it now, so that our new `ec_common/src/events.rs` file is a common location for events defined up to this point, and looks like this: +```rust + +//! Common types and utilities for the embedded controller (EC) ecosystem. +/// BatteryEvent is defined at `battery_service::context::BatteryEvent` +/// ChargerEvent is defined at `embedded_services::power::policy::charger::ChargerEvent` + +/// -------------------- Thermal -------------------- + +/// Events to announce thermal threshold crossings +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThresholdEvent { + None, + OverHigh, + UnderLow +} + +/// Request to increase or decrease cooling efforts +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CoolingRequest { + Increase, + Decrease +} + +/// Resulting values to apply to accommodate request +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CoolingResult { + pub new_level: u8, + pub target_rpm_percent: u8, + pub spinup: Option, +} + +/// One-shot spin-up hint: force RPM for a short time so the fan actually starts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpinUp { + pub rpm: u16, + pub hold_ms: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThermalEvent { + TempSampleC100(u16), // (int) temp_c * 100 + Threshold(ThresholdEvent), + CoolingRequest(CoolingRequest) +} +``` +Now all of our event messaging can be referred to from the single enumeration source `BusEvent`, and our handlers can dispatch accordingly. + + +Edit `thermal_project/mock_thermal/src/mock_sensor_controller.rs` to remove the definition of `ThresholdEvent` there, and add the following import: + +```rust +use ec_common::events::ThresholdEvent; +``` + +Edit `thermal_project/mock_thermal/src/mock_fan_controller.rs` to remove the definitions of `CoolingRequest`, `CoolingResult`, and `SpinUp`, and add the following imports: + +```rust +use ec_common::events::{CoolingRequest, CoolingResult, SpinUp}; +``` + +We also need to add this to the `lib.rs` file of `ec_common` to make these types available to the rest of the crate: + +```rust +pub mod mutex; +pub mod mut_copy; +pub mod espi_service; +pub mod fuel_signal_ready; +pub mod test_helper; +pub mod events; +``` + +Try a `cargo build` (or a `cargo check`) in `thermal_project` to ensure that everything is still compiling correctly. If you have not made any other changes there, it should compile without errors. diff --git a/guide_book/src/how/ec/integration/20-charger_attachment.md b/guide_book/src/how/ec/integration/20-charger_attachment.md new file mode 100644 index 0000000..1d00b3d --- /dev/null +++ b/guide_book/src/how/ec/integration/20-charger_attachment.md @@ -0,0 +1,67 @@ +# Checking Charger Attachment + +Let's continue on with the next step we've outlined in our `TestStep` series: `TestStep::CheckChargerAttach`. + +To do this, create a new member function for this: +```rust + fn check_charger_attach(&mut self, mins_passed: f32, soc:f32, _draw_watts:f32, charge_watts:f32) -> TestStep{ + let reporter = &mut self.reporter; + // Fail if we don't see our starting conditions within a reasonable time + if mins_passed > 30.0 { // should occur before 30 minutes simulation time + add_test!(reporter, "Attach Charger", |obs| { + obs.fail("Time expired waiting for attach"); + }); + } + // wait until we see evidence of charger attachment + if charge_watts == 0.0 { + return TestStep::CheckChargerAttach; // stay on this task + } + add_test!(reporter, "Check Charger Attachment", |obs| { + expect!(obs, soc <= 90.0, "Attach expected <= 90% SOC"); + }); + TestStep::EndAndReport // go to next step + } +``` +This is a little different because it first checks for qualifying (or disqualifying error) conditions before it begins the actual test closure. +First, it checks to see if we've timed out -- using simulation time, and assuming the starting values that we've already verified, we expect the battery to discharge to the attach point in under 30 minutes. If this condition fails, we create a directly failing test to report it. +We then check to see if the charger is attached, which is evidenced by `charge_watts > 0.0` until this is true, we return `TestStep::CheckChargerAttach` so that we continue to be called each frame until then. +Once these conditional checks are done, we can test what it means to be in attached state and proceed to the next step, which in this case is `EndAndReport` until we add another test. + +On that note, edit the return of `check_starting_values()` to now be `TestStep::CheckChargerAttach`. + +Now, in the match arms for this, add this caller: +```rust + TestStep::CheckChargerAttach => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let soc = dv.soc_percent; + let draw_watts = dv.draw_watts; + let charge_watts = dv.charge_watts; + + self.step = self.check_charger_attach(mins_passed, soc, draw_watts, charge_watts); + + }, +``` + +finally, remove this `println!` because these will start to become annoying at this point: +```rust + println!("Step {:?}", self.step); +``` + +and when run with `cargo run --features integration-test` you should see: +``` +🔬 Integration testing starting... + ☄ attaching charger +================================================== + Test Section Report +[PASS] Static Values received +[PASS] First Test Data Frame received +[PASS] Check Starting Values +[PASS] Check Charger Attachment + + Summary: total=4, passed=4, failed=0 +================================================== +``` + +Next, we'll look at what the effects of increasing the system load have to our scenario, but first we need to provide a mechanism for that. + + diff --git a/guide_book/src/how/ec/integration/21-affecting_change.md b/guide_book/src/how/ec/integration/21-affecting_change.md new file mode 100644 index 0000000..49ed631 --- /dev/null +++ b/guide_book/src/how/ec/integration/21-affecting_change.md @@ -0,0 +1,252 @@ +# Affecting Change in the tests + +For our next test, we want to raise the system load and then see how that affects temperature (it should rise). + +We don't currently have a way to tell the simulation to raise the load. But in interactive mode we can, and we did that by sending `InteractionEvent` messages. Let's do that here. We'll need to pass in the `InteractionChannelWrapper` we need for sending these messages into the `interaction_test()` function. + +Start by adding these imports: +```rust + use crate::entry::InteractionChannelWrapper; + use ec_common::espi_service::EventChannel; + use crate::events::InteractionEvent; +``` +Then, change the signature for `interaction_test()` to accept the new parameter: +```rust + #[embassy_executor::task] + pub async fn integration_test(rx: &'static DisplayChannelWrapper, tx:&'static InteractionChannelWrapper) { +``` +Now, unlike the `rx` parameter that we use within the body of the function, we need this `tx` parameter available to us while we are in the test code -- and therefore the `ITest` structure itself, so we need to add it as a member and pass it in on the constructor: + +```rust + struct ITest { + reporter: TestReporter, + tx: &'static InteractionChannelWrapper, + ... + } +``` +and +```rust + impl ITest { + pub fn new(tx:&'static InteractionChannelWrapper) -> Self { + let mut reporter = TestReporter::new(); + reporter.start_test_section(); // start out with a new section + Self { + reporter, + tx, + ... +``` +and pass `tx` in the `ITest` constructor in this code at the bottom of the `integration_test()` function: +```rust + let mut r = DisplayRenderer::new(RenderMode::IntegrationTest); + r.set_test_tap(ITest::new(tx)).unwrap(); + r.run(rx).await; +``` + +Note that the `rx` (Display) Channel is consumed entirely within the `DisplayRenderer` `run` loop, whereas our `tx` (Interaction) Channel must be available to us in `ITest` for ad-hoc sending of `InteractionEvent` messages within our test steps, thus the way we've bifurcated the usage of these here. + +Now we are set up to call on interaction event to increase and decrease the load, as we will use in the next test. + +Our `TestStep` enum for this is `RaiseLoadAndCheckTemp`. +Create a new member function to handle this: + +```rust +fn raise_load_and_check_temp(&mut self, mins_passed:f32, draw_watts: f32, temp_c:f32) -> TestStep { + let reporter = &mut self.reporter; + + TestStep::EndAndReport +} +``` +We'll fill it out later. First, we need to add some helper members we can use to track time and temperature. + +Add these members to the `ITest` struct: +```rust + mark_time: Option, + mark_temp: Option, +``` +and initialize them as `None`: +```rust + mark_time: None, + mark_temp: None, +``` +Now, fill out our `raise_load_and_check_temp` function to look like this: +```rust + fn raise_load_and_check_temp(&mut self, mins_passed:f32, draw_watts: f32, temp_c:f32) -> TestStep { + + let reporter = &mut self.reporter; + + if self.mark_time == None { + self.mark_time = Some(mins_passed); + self.mark_temp = Some(temp_c); + } + + if draw_watts < 20.0 { // raise to something above 20 then stop pumping it up + let _ = self.tx.try_send(InteractionEvent::LoadUp); + return TestStep::RaiseLoadAndCheckTemp + } + let mt = *self.mark_time.get_or_insert(mins_passed); + let time_at_charge = if mins_passed > mt { mins_passed - mt } else { 0.0 }; + if time_at_charge > 0.5 { // after about 30 seconds, check temperature + let temp_raised = self.mark_temp.map_or(0.0, |mt| if temp_c > mt { temp_c - mt } else { 0.0 }); + add_test!(reporter, "Temperature rises on charge", |obs| { + expect!(obs, temp_raised > 1.5, "Temp should rise noticeably"); + }); + } else { + // keep going + return TestStep::RaiseLoadAndCheckTemp + } + // reset in case we want to use these again later + self.mark_temp = None; + self.mark_time = None; + TestStep::EndAndReport + } +``` +What we do here is mark the time when we first get in, then we bump up the the load using our new `tx` member until we see that the load is something above 20w. At that point we check the time to see if at least 1/2 a minute has passed. Until these conditions are met, we keep returning `TestStep::RaiseLoadAndCheckTemp` to keep us evaluating this state. Once there, we check how high the temperature has risen since the last check, relative to the marked baseline. We expect it to be around 2 degrees, give or take, so we'll check for 1.5 degrees or more as our test. We then go to the next step (for now, `EndAndReport`), but before we do we reset our `Option` marks in case we want to reuse them in subsequent tests. + +Remember to change the return value of `check_charger_attach` to go to `TestStep::RaiseLoadAndCheckTemp` also, or this test won't fire. + +Then add the calling code in the match arms section below: +```rust + TestStep::RaiseLoadAndCheckTemp => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let load_watts = dv.draw_watts; + let temp_c = dv.temp_c; + + self.step = self.raise_load_and_check_temp(mins_passed, load_watts, temp_c); + }, +``` + +## Checking the Fan +The next test we'll create is similar, but in this case, we'll raise the load (and heat) significantly enough for the system fan to kick in. + +Create the member function we'll need for this. it will look much like the previous one in many ways: +```rust + fn raise_load_and_check_fan(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + let reporter = &mut self.reporter; + + // record time we started this + if self.mark_time == None { + self.mark_time = Some(mins_passed); + } + + if draw_watts < 39.0 { // raise to maximum + let _ = self.tx.try_send(InteractionEvent::LoadUp); + return TestStep::RaiseLoadAndCheckFan + } + let mt = *self.mark_time.get_or_insert(mins_passed); + let time_elapsed = if mins_passed > mt { mins_passed - mt } else { 0.0 }; + if time_elapsed > 0.25 && fan_level == 0 { // this should happen relatively quickly (about 15 seconds of sim time) + add_test!(reporter, "Timed out waiting for fan", |obs| { + obs.fail("Time expired"); + }); + return TestStep::EndAndReport // end the test now on timeout error + } + + if fan_level > 0 { + add_test!(reporter, "Fan turns on", |obs| { + obs.pass(); + }); + add_test!(reporter, "Temperature is warm", |obs| { + expect!(obs, temp_c >= 28.0, "temp below fan on range"); + }); + } else { + // keep going + return TestStep::RaiseLoadAndCheckFan + } + // reset in case we want to use these again later + self.mark_temp = None; + self.mark_time = None; + TestStep::EndAndReport + } +``` +add the calling case to the match arm: +```rust + TestStep::RaiseLoadAndCheckFan => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let draw_watts = dv.draw_watts; + let temp_c = dv.temp_c; + let fan_level = dv.fan_level; + + self.step = self.raise_load_and_check_fan(mins_passed, draw_watts, temp_c, fan_level); + }, + +``` +Don't forget to update the next step return of the previous step so that it carries forward to this one. + +## Time to Chill +Great! Now, let's make sure the temperature goes back down with less demand on the system and that the fan backs off when cooling is complete. + +Create the member function +```rust + fn lower_load_and_check_cooling(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + let reporter = &mut self.reporter; + + // record time and temp when we started this + if self.mark_time == None { + self.mark_time = Some(mins_passed); + self.mark_temp = Some(temp_c); + } + + // drop load back to low + if draw_watts > 10.0 { + let _ = self.tx.try_send(InteractionEvent::LoadDown); + return TestStep::LowerLoadAndCheckCooling + } + // wait a bit + let mark_time = *self.mark_time.get_or_insert(mins_passed); + let diff = mins_passed - mark_time; + if diff > 60.0 { // wait for an hour for it to cool all the way + return TestStep::LowerLoadAndCheckCooling + } + + add_test!(reporter, "Cooled", |obs| { + expect!(obs, draw_watts < 10.0, "Load < 10 W"); + println!("temp is {}", temp_c); + expect!(obs, temp_c < 25.5, "Temp is < 25.5"); + }); + add_test!(reporter, "Fan turns off", |obs| { + expect_eq!(obs, fan_level, 0); + }); + // reset in case we want to use these again later + self.mark_temp = None; + self.mark_time = None; + TestStep::EndAndReport + } + +``` +and the caller in the match arm: +```rust + TestStep::LowerLoadAndCheckCooling => { + let mins_passed = dv.sim_time_ms / 60_000.0; + let draw_watts = dv.draw_watts; + let temp_c = dv.temp_c; + let fan_level = dv.fan_level; + + self.step = self.lower_load_and_check_cooling(mins_passed, draw_watts, temp_c, fan_level); + }, +``` +And again, remember to update the return value for the next step of the `load_and_check_fan` method to be `TestStep::LowerLoadAndCheckCooling` so that it chains to this one properly. + +Your `cargo run --features integration-test` should now complete in about 40 seconds and look like this (your output timing may vary slightly): +``` +================================================== + Test Section Report + Duration: 38.2245347s + +[PASS] Static Values received +[PASS] First Test Data Frame received +[PASS] Check Starting Values +[PASS] Check Charger Attachment +[PASS] Temperature rises on charge +[PASS] Temperature is warm +[PASS] Fan turns on +[PASS] Cooled +[PASS] Fan turns off + + Summary: total=9, passed=9, failed=0 +================================================== +``` + + + + + diff --git a/guide_book/src/how/ec/integration/22-summary_thoughts.md b/guide_book/src/how/ec/integration/22-summary_thoughts.md new file mode 100644 index 0000000..ad321e6 --- /dev/null +++ b/guide_book/src/how/ec/integration/22-summary_thoughts.md @@ -0,0 +1,31 @@ +# Summary Thoughts + +Congratulations on your success in building and integrating, understanding, and testing virtual components using the ODP framework! + +There are, of course, a number of things that one would do differently if they were working with actual hardware and building for an actual system. + +For starters, much of the behavioral code we created in these exercises needed to substitute simulated time and physical responses that would "just happen" with real hardware in real time, and real-world physics may differ from our simplified models here. + +We use `println!` pretty liberally in our examples -- this is fine since we are building for a std environment with these apps currently, but when migrating to a target build, these will need to replaced by `log::debug!` or `info!` and limited in context. + +With the simplistic integration we have here, the battery is always the source of power -- it will charge, but if the load exceeds the charger ability, the battery will eventually fail even when the device is presumably plugged in. This is an artifact of our simplified model. A real Embedded Controller integrations power-path control so that mains input can bypass the battery when not available. Readers are encouraged to look into the usb-pd support found in the [ODP usb-pd repository](https://github.com/OpenDevicePartnership/embedded-usb-pd) for resources that can be used in extending the integration to support a "plugged-in" mode. + +## Where do we go from here? + +There are some key steps ahead before one can truly claim to have an EC integration ready: +- __Hardware__ - We have not yet targeted for actual embedded hardware -- whether simulated behavior is used or not -- that is coming up in the next set of exercises. This could target an actual hardware board or perhaps a QEMU virtual system as an intermediary step. +- __Standard Bus Interface__ - To connect the EC into a system, we would need to adopt a standard bus interface -- most likely __ACPI__ +- __Security__ - We have conceptually touched upon security, but have not made any implementation efforts to support such mechanisms. A real-world system _must_ address these. In environments that support it, a Hafnium-based hypervisor implementation for switching EC services into context is recommended. + +## The ODP EC Test App +Once a complete EC is constructed, there is a very nice test app produced by the ODP that can be used to validate that the ACPI plumbing is correct and the EC responds to calls with the expected arguments in and return values back. + +[ODP ec-test-app](https://github.com/OpenDevicePartnership/ec-test-app) + +At this point, you have the building blocks in hand to extend your virtual EC toward this validation path by adding ACPI plumbing on top of the Rust components we've built and exposing them in a QEMU or hardware container. + +The ec-test-app repo even includes sample ACPI tables (thermal + notification examples) to show how the methods are expected to be defined. That could be a starting point for the essential bridge between the Rust-based EC simulation examples we've worked with, and the Windows validation world for a true device. + + + + diff --git a/guide_book/src/how/ec/integration/3-better_alloc.md b/guide_book/src/how/ec/integration/3-better_alloc.md new file mode 100644 index 0000000..e2ceb7c --- /dev/null +++ b/guide_book/src/how/ec/integration/3-better_alloc.md @@ -0,0 +1,27 @@ +# Improving allocation strategies +In all of our previous examples, we have used the `StaticCell` type to manage our component allocations. This has worked well for our simple examples, but it was never the best approach. Most notably, it forces us use the `duplicate_static_mut!` macro that uses declared `unsafe` casts to allow us to borrow a mutable reference more than once. This is not a good practice, and we should avoid it if possible. +Fortunately, there is an alternative. It's not perfect, but it does allow us to resolve a static with more than a one-time call to `init` the way `StaticCell` does. `OnceLock` is a type that is defined in both `std::sync` and in `embassy::sync`. The `embassy` version is designed to work in an embedded context, and supports an asynchronous context, so we will use this version for our examples. +## Using `OnceLock` +The `OnceLock` type is a synchronization primitive that allows us to initialize a value once, and then access it multiple times. While this might seem to be the obvious alternative to `StaticCell`, it does have some limitations. Most notably, it does not allow us to borrow a mutable reference to the value after it has been initialized. This means that we cannot use it to manage mutable state in the same way that we do with `StaticCell`. So if we need more than one mutable reference to a value, we would still need to use `StaticCell` + `duplicate_static_mut!` or some other approach. + +Fortunately, we have another approach in mind. + +### Changing to `OnceLock` here +In earlier examples we used `StaticCell` (and oftentimes `duplicate_static_mut!`) to construct global singletons and pass `&'static mut references` into tasks. That worked in context, but it becomes easy to paint oneself into a corner: once `&'static mut` is handed out, it can be tempting to duplicate it, which breaks the `unsafe` guarantees and can violate Rust’s aliasing rules. + +`embassy_sync::OnceLock` provides a safer pattern for most globals. It lets us initialize a value exactly once (`get_or_init`) and __await its availability__ from any task (`get().await`) - avoiding the need for separate 'ready' signals. Combined with interior mutability (`Mutex`), we can share mutable state safely across tasks without ever forging multiple `&'static mut` aliases. + +> ## OnceLock vs. StaticCell +> - `StaticCell` provides a mutable reference. A mutable reference may be more useful for accessing internals. +> - `OnceLock` provides a non-mutable reference. It may not be as useful, but can be passed about freely +> - a `OnceLock` containing a `Mutex` to a `StaticCell` entity may be passed around freely and the mutex can resolve a mutable reference. +> ----- + + +We still keep `StaticCell` for the cases where a library requires a true `&'static mut` for its entire lifetime. Everywhere else, `OnceLock + Mutex` is simpler, safer, and matches Embassy’s concurrency model. + +We will be switching to this pattern in our examples going forward, but we will not necessarily update previous usage in the previously existing example code. +Consider the old patterns we have learned up to now to be deprecated. This new paradigm can be a little awkward to bend one's head around at first, but the simplicity and safety of the end result is undeniable. + + + diff --git a/guide_book/src/how/ec/integration/4-update_controller.md b/guide_book/src/how/ec/integration/4-update_controller.md new file mode 100644 index 0000000..56816a1 --- /dev/null +++ b/guide_book/src/how/ec/integration/4-update_controller.md @@ -0,0 +1,450 @@ +# Updating the Controller construction +Continuing our revisions to eliminate the unnecessary use of `duplicate_static_mut!` and adhere to a more canonical pattern that respects single ownership rules, we need to update the constructor for our component Controllers. +We also want to remove the unnecessary use of Generics in the design of these controllers. We are only creating one variant of controller, there is no need to specify in a generic component in these cases, and generics are not only awkward to work with, they come with a certain amount of additional overhead. This would be fine if we were actually taking advantage of different variant implementations, but since we are not, let's eliminate this now and simplify our design as long as we are making changes anyway. + +## Updating the `MockChargerController` Constructor +The `MockChargerController` currently takes both a `MockCharger` and a `MockChargerDevice` parameter. The Controller doesn't actually use the `Device` context -- The `Device` is used to register the component with the service separately, but the component passed to the Controller must be the same as the one registered. + +For the `MockBatteryController`, we got around this by not passing the `MockBatteryDevice`, since it isn't used. For the thermal components, `MockSensorController` and `MockFanController` are passed only the component `Device` instance and the component reference is extracted from here. + +This latter approach is a preferable pattern because it ensures that the same component instance used for registration is also the one provided to the controller. + +We use the `get_internals()` method to return both the component and the `Device` instances instead of simply `inner_charger` because splitting the reference avoids inherent internal borrows on the same mutable 'self' reference. + +Update `charger_project/mock_charger/src/mock_charger_controller.rs` so that the `MockChargerController` definition itself looks like this: + +```rust +pub struct MockChargerController { + pub charger: &'static mut MockCharger, + pub device: &'static mut Device +} + +impl MockChargerController +{ + pub fn new(device: &'static mut MockChargerDevice) -> Self { + let (charger, device) = device.get_internals(); + Self { charger, device } + } +} +``` +and then replace any references in the code of +```rust +self.device.inner_charger() +``` +to become +```rust +self.charger +``` +and lastly, change +```rust +let inner = controller.charger; +``` +to become +```rust +let inner = &controller.charger; +``` +to complete the revisions for `MockChargerController`. + +>📌 Why does `get_internals()` work where inner_charger() fails? +> +> This boils down to how the borrow checker sees the lifetime of the borrows. +> +> `inner_charger()` returns only `&mut MockCharger`,but the MockChargerDevice itself is still alive in the same scope. If you then also try to hand out `&mut Device` later, Rust sees that as two overlapping mutable borrows of the same struct, which is illegal. +> +> `get_internals()` instead performs both borrows inside the same method call and returns them as a tuple. +> +> This is a pattern the compiler can prove safe: it knows exactly which two disjoint fields are being borrowed at once, and it enforces that they don’t overlap. +> +> This is why controllers like our `MockSensorController`, `MockFanController`, and now `MockChargerController` can be cleanly instantiated with `get_internals()`. The `MockBatteryController` happens not to need this because it never touches the `Device` half of `MockBatteryDevice` — it only needs the component itself. + +### The other Controllers +In our `MockSensorController` and `MockFanController` definitions, we did not make our `Device` or component members accessible, so we will change those now to do that and make them public: + +In `mock_sensor_controller.rs`: +```rust +pub struct MockSensorController { + pub sensor: &'static mut MockSensor, + pub device: &'static mut Device +} + +/// +/// Temperature Sensor Controller +/// +impl MockSensorController { + pub fn new(device: &'static mut MockSensorDevice) -> Self { + let (sensor, device) = device.get_internals(); + Self { + sensor, + device + } + } + +``` + +In `mock_fan_controller.rs`: +```rust +pub struct MockFanController { + pub fan: &'static mut MockFan, + pub device: &'static mut Device +} + +/// Fan controller. +/// +/// This type implements [`embedded_fans_async::Fan`] and **inherits** the default +/// implementations of [`Fan::set_speed_percent`] and [`Fan::set_speed_max`]. +/// +/// Those methods are available on `MockFanController` without additional code here. +impl MockFanController { + pub fn new(device: &'static mut MockFanDevice) -> Self { + let (fan, device) = device.get_internals(); + Self { + fan, + device + } + } +``` + +We mentioned that we originally implemented `MockBatteryController` as being constructed without a `Device` element, but we _will_ need to access this device context later, so we should expose that as public member in the same way. While we are at it, we should also eliminate the generic design of the structure definition, since it is only adding unnecessary complexity and inconsistency. + +Update `battery_project/mock_battery/mock_battery_component.rs` so that it now looks like this (consistent with the others): + +```rust +use battery_service::controller::{Controller, ControllerEvent}; +use battery_service::device::{DynamicBatteryMsgs, StaticBatteryMsgs}; +use embassy_time::{Duration, Timer}; +use crate::mock_battery::{MockBattery, MockBatteryError}; +use crate::mock_battery_device::MockBatteryDevice; +use embedded_services::power::policy::device::Device; +use embedded_batteries_async::smart_battery::{ + SmartBattery, ErrorType, + ManufactureDate, SpecificationInfoFields, CapacityModeValue, CapacityModeSignedValue, + BatteryModeFields, BatteryStatusFields, + DeciKelvin, MilliVolts +}; + +pub struct MockBatteryController { + /// The underlying battery instance that this controller manages. + pub battery: &'static mut MockBattery, + pub device: &'static mut Device + +} + +impl MockBatteryController +{ + pub fn new(battery_device: &'static mut MockBatteryDevice) -> Self { + let (battery, device) = battery_device.get_internals(); + Self { + battery, + device + } + } +} + +impl ErrorType for MockBatteryController +{ + type Error = MockBatteryError; +} +impl SmartBattery for MockBatteryController +{ + async fn temperature(&mut self) -> Result { + self.battery.temperature().await + } + + async fn voltage(&mut self) -> Result { + self.battery.voltage().await + } + + async fn remaining_capacity_alarm(&mut self) -> Result { + self.battery.remaining_capacity_alarm().await + } + + async fn set_remaining_capacity_alarm(&mut self, _: CapacityModeValue) -> Result<(), Self::Error> { + self.battery.set_remaining_capacity_alarm(CapacityModeValue::MilliAmpUnsigned(0)).await + } + + async fn remaining_time_alarm(&mut self) -> Result { + self.battery.remaining_time_alarm().await + } + + async fn set_remaining_time_alarm(&mut self, _: u16) -> Result<(), Self::Error> { + self.battery.set_remaining_time_alarm(0).await + } + + async fn battery_mode(&mut self) -> Result { + self.battery.battery_mode().await + } + + async fn set_battery_mode(&mut self, _: BatteryModeFields) -> Result<(), Self::Error> { + self.battery.set_battery_mode(BatteryModeFields::default()).await + } + + async fn at_rate(&mut self) -> Result { + self.battery.at_rate().await + } + + async fn set_at_rate(&mut self, _: CapacityModeSignedValue) -> Result<(), Self::Error> { + self.battery.set_at_rate(CapacityModeSignedValue::MilliAmpSigned(0)).await + } + + async fn at_rate_time_to_full(&mut self) -> Result { + self.battery.at_rate_time_to_full().await + } + + async fn at_rate_time_to_empty(&mut self) -> Result { + self.battery.at_rate_time_to_empty().await + } + + async fn at_rate_ok(&mut self) -> Result { + self.battery.at_rate_ok().await + } + + async fn current(&mut self) -> Result { + self.battery.current().await + } + + async fn average_current(&mut self) -> Result { + self.battery.average_current().await + } + + async fn max_error(&mut self) -> Result { + self.battery.max_error().await + } + + async fn relative_state_of_charge(&mut self) -> Result { + self.battery.relative_state_of_charge().await + } + + async fn absolute_state_of_charge(&mut self) -> Result { + self.battery.absolute_state_of_charge().await + } + + async fn remaining_capacity(&mut self) -> Result { + self.battery.remaining_capacity().await + } + + async fn full_charge_capacity(&mut self) -> Result { + self.battery.full_charge_capacity().await + } + + async fn run_time_to_empty(&mut self) -> Result { + self.battery.run_time_to_empty().await + } + + async fn average_time_to_empty(&mut self) -> Result { + self.battery.average_time_to_empty().await + } + + async fn average_time_to_full(&mut self) -> Result { + self.battery.average_time_to_full().await + } + + async fn charging_current(&mut self) -> Result { + self.battery.charging_current().await + } + + async fn charging_voltage(&mut self) -> Result { + self.battery.charging_voltage().await + } + + async fn battery_status(&mut self) -> Result { + self.battery.battery_status().await + } + + async fn cycle_count(&mut self) -> Result { + self.battery.cycle_count().await + } + + async fn design_capacity(&mut self) -> Result { + self.battery.design_capacity().await + } + + async fn design_voltage(&mut self) -> Result { + self.battery.design_voltage().await + } + + async fn specification_info(&mut self) -> Result { + self.battery.specification_info().await + } + + async fn manufacture_date(&mut self) -> Result { + self.battery.manufacture_date().await + } + + async fn serial_number(&mut self) -> Result { + self.battery.serial_number().await + } + + async fn manufacturer_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.manufacturer_name(v).await + } + + async fn device_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_name(v).await + } + + async fn device_chemistry(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_chemistry(v).await + } +} + +impl Controller for MockBatteryController +{ + type ControllerError = MockBatteryError; + + async fn initialize(&mut self) -> Result<(), Self::ControllerError> { + Ok(()) + } + + async fn get_static_data(&mut self) -> Result { + let mut name = [0u8; 21]; + let mut device = [0u8; 21]; + let mut chem = [0u8; 5]; + + println!("MockBatteryController: Fetching static data"); + + self.battery.manufacturer_name(&mut name).await?; + self.battery.device_name(&mut device).await?; + self.battery.device_chemistry(&mut chem).await?; + + let capacity = match self.battery.design_capacity().await? { + CapacityModeValue::MilliAmpUnsigned(v) => v, + _ => 0, + }; + + let voltage = self.battery.design_voltage().await?; + + // This is a placeholder, replace with actual logic to determine chemistry ID + // For example, you might have a mapping of chemistry names to IDs + let chem_id = [0x01, 0x02]; // example + + // Serial number is a 16-bit value, split into 4 bytes + // where the first two bytes are zero + let raw = self.battery.serial_number().await?; + let serial = [0, 0, (raw >> 8) as u8, (raw & 0xFF) as u8]; + + Ok(StaticBatteryMsgs { + manufacturer_name: name, + device_name: device, + device_chemistry: chem, + design_capacity_mwh: capacity as u32, + design_voltage_mv: voltage, + device_chemistry_id: chem_id, + serial_num: serial, + }) + } + + + async fn get_dynamic_data(&mut self) -> Result { + println!("MockBatteryController: Fetching dynamic data"); + + // Pull values from SmartBattery trait + let full_capacity = match self.battery.full_charge_capacity().await? { + CapacityModeValue::MilliAmpUnsigned(val) => val as u32, + _ => 0, + }; + + let remaining_capacity = match self.battery.remaining_capacity().await? { + CapacityModeValue::MilliAmpUnsigned(val) => val as u32, + _ => 0, + }; + + let battery_status = { + let status = self.battery.battery_status().await?; + // Bit masking matches the SMS specification + let mut result: u16 = 0; + result |= (status.fully_discharged() as u16) << 0; + result |= (status.fully_charged() as u16) << 1; + result |= (status.discharging() as u16) << 2; + result |= (status.initialized() as u16) << 3; + result |= (status.remaining_time_alarm() as u16) << 4; + result |= (status.remaining_capacity_alarm() as u16) << 5; + result |= (status.terminate_discharge_alarm() as u16) << 7; + result |= (status.over_temp_alarm() as u16) << 8; + result |= (status.terminate_charge_alarm() as u16) << 10; + result |= (status.over_charged_alarm() as u16) << 11; + result |= (status.error_code() as u16) << 12; + result + }; + + let relative_soc_pct = self.battery.relative_state_of_charge().await? as u16; + let cycle_count = self.battery.cycle_count().await?; + let voltage_mv = self.battery.voltage().await?; + let max_error_pct = self.battery.max_error().await? as u16; + let charging_voltage_mv = 0; // no charger implemented yet + let charging_current_ma = 0; // no charger implemented yet + let battery_temp_dk = self.battery.temperature().await?; + let current_ma = self.battery.current().await?; + let average_current_ma = self.battery.average_current().await?; + + // For now, placeholder sustained/max power + let max_power_mw = 0; + let sus_power_mw = 0; + + Ok(DynamicBatteryMsgs { + max_power_mw, + sus_power_mw, + full_charge_capacity_mwh: full_capacity, + remaining_capacity_mwh: remaining_capacity, + relative_soc_pct, + cycle_count, + voltage_mv, + max_error_pct, + battery_status, + charging_voltage_mv, + charging_current_ma, + battery_temp_dk, + current_ma, + average_current_ma, + }) + } + + async fn get_device_event(&mut self) -> ControllerEvent { + loop { + Timer::after(Duration::from_secs(60)).await; + } + } + + async fn ping(&mut self) -> Result<(), Self::ControllerError> { + Ok(()) + } + + fn get_timeout(&self) -> Duration { + Duration::from_secs(10) + } + + fn set_timeout(&mut self, _duration: Duration) { + // Ignored for mock + } +} +``` + +Now we have a consistent and rational pattern to each of our controller models. + +As previously mentioned, note that these changes break the constructor calling in the previous example exercises, so if you are intent on keeping the previous exercises building, you will need to refactor those. +You would need to change any references to MockBatteryController in any of the existing code that uses the former version to be simply `MockBatteryController` and will need to update any calls to the constructor to pass the `MockBatteryDevice` instance instead of `MockBattery`. +There are likely other ramifications with regard to multiple borrows that still remain in the previous code that you will have to choose how to mitigate as well. + +### As long as we're updating controllers... +An oversight in the implementation of some of the `SmartBattery` traits of `MockBatteryController` fail to pass the buffer parameter down into the underlying implementation. Although this won't materially affect the build here, it should be remedied. Replace these methods in `battery_project/mock_battery/src/mock_battery_controller.rs` with the versions below: + +```rust + async fn manufacturer_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.manufacturer_name(buf).await + } + + async fn device_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_name(buf).await + } + + async fn device_chemistry(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_chemistry(buf).await + } +``` + + + + + + + + diff --git a/guide_book/src/how/ec/integration/5-structural_steps.md b/guide_book/src/how/ec/integration/5-structural_steps.md new file mode 100644 index 0000000..0361da8 --- /dev/null +++ b/guide_book/src/how/ec/integration/5-structural_steps.md @@ -0,0 +1,249 @@ +# The Structural Steps of our new Integration +Before we start implementing, it is worth setting the overview and expectations for how we will bring up this new integrated scaffolding. + +Like most applications, ours starts with the `main()` function. In deference to a later targeting to an embedded build, we will mark this as an `#[embassy_executor::main]`, which saves us the trouble of spinning up our own instance of embassy-executor in order to spawn our tasks. Nevertheless, the `main()` function's only job here is to spin up the `entry_task_interactive` start point (later, we'll have a separate, similar entry point for test mode). + +Put this content into your `main.rs`: + +```rust + +use embassy_executor::Spawner; + +mod config; +mod policy; +mod model; +mod state; +mod events; +mod entry; +mod setup_and_tap; +mod controller_core; + +#[embassy_executor::main] +async fn main(spawner: Spawner) { + + spawner.spawn(entry::entry_task_interactive(spawner)).unwrap(); +} +``` + +note the `mod` lines here that bring in the configuration definitions we constructed previously, as well as our new consolidated local `events.rs`. This is similar to what we did using `lib.rs` in previous examples, but since this is a singular app and not a crate, per-se, we will use `main.rs` as the aggregation point for external files to include. + +You will see that in addition to the configuration files we have already created, we also make reference to the following: +- `entry` +- `setup_and_tap` +- `controller_core` + +You can go ahead and create these files in `src` (`entry.rs`, `setup_and_tap.rs`, `controller_core.rs`) and leave them empty for now, as we will be filling them out in the next few steps. + +First, let's explain what we have in mind for each of them: + +We will place our `entry_task_interactive` in a new file we create named `entry.rs`, which we will construct in a moment. This file will be responsible mostly for allocation of our components and the definition of our new comm "channels". + +Next in line for the startup of our scaffolding is contained in a file we will name `setup_and_tap.rs`. This file is responsible for initializing the components and the services. The `tap` part of its name comes from the nature of how we interface with the Battery service inherent in `embedded-services`. As we noted earlier, in previous exercises, we prepared for using this service, but never actually did use it, and therefore needed to do much of our own event wiring rather than adhere to the default event sequence ODP provides for us. To actually use it, we must give ownership of our BatteryController to the service, and use the callbacks into the trait methods invoked by messages to gain access to our wider integrated scope (hence `tap`ping into it). +Our _"wider integrated scope"_ is represented by `controller_core.rs` where we will implement the required traits necessary for a `Battery` Controller so that we can give it to the Battery service, while keeping all of our actual components held close as member properties. This allows us to treat the integration as a unified whole without breaking ownership rules. + +## The beginnings of entry.rs + +`entry.rs` defines the thin wrappers we put around our new comm Channel implementations (replacing `EspiService`), and it establishes the static residence for many of our top-level components. + +Let's start it out with this content: +```rust +use static_cell::StaticCell; + +use embassy_sync::channel::Channel; +use embassy_sync::once_lock::OnceLock; + +use ec_common::mutex::RawMutex; +use ec_common::espi_service::{ + EventChannel, MailboxDelegateError +}; +use ec_common::fuel_signal_ready::BatteryFuelReadySignal; +use ec_common::events::ThermalEvent; + +use battery_service::context::BatteryEvent; +use battery_service::device::{Device as BatteryDevice, DeviceId as BatteryServiceDeviceId}; + +use embedded_services::power::policy::charger::ChargerEvent; + +pub const BATTERY_DEV_NUM: u8 = 1; +pub const CHARGER_DEV_NUM: u8 = 2; +pub const SENSOR_DEV_NUM: u8 = 3; +pub const FAN_DEV_NUM: u8 = 4; + + +// ---------- Channels as thin wrappers ---------- +const CHANNEL_CAPACITY:usize = 16; + +pub struct BatteryChannelWrapper(pub Channel); +#[allow(unused)] +impl BatteryChannelWrapper { + pub async fn send(&self, e: BatteryEvent) { self.0.send(e).await } + pub async fn receive(&self) -> BatteryEvent { self.0.receive().await } +} +impl EventChannel for BatteryChannelWrapper { + type Event = BatteryEvent; + fn try_send(&self, e: BatteryEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct ChargerChannelWrapper(pub Channel); +impl EventChannel for ChargerChannelWrapper { + type Event = ChargerEvent; + fn try_send(&self, e: ChargerEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct ThermalChannelWrapper(pub Channel); +impl EventChannel for ThermalChannelWrapper { + type Event = ThermalEvent; + fn try_send(&self, e: ThermalEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct DisplayChannelWrapper(pub Channel); +#[allow(unused)] +impl DisplayChannelWrapper { + pub async fn send(&self, e: DisplayEvent) { self.0.send(e).await } + pub async fn receive(&self) -> DisplayEvent { self.0.receive().await } +} +impl EventChannel for DisplayChannelWrapper { + type Event = DisplayEvent; + fn try_send(&self, e: DisplayEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct InteractionChannelWrapper(pub Channel); +impl InteractionChannelWrapper { + pub async fn send(&self, e: InteractionEvent) { self.0.send(e).await } + pub async fn receive(&self) -> InteractionEvent { self.0.receive().await } +} +impl EventChannel for InteractionChannelWrapper { + type Event = InteractionEvent; + fn try_send(&self, e: InteractionEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} + +// ---------- Statics ---------- +// Keep StaticCell for things that truly need &'static mut (exclusive owner) +static BATTERY_FUEL: StaticCell = StaticCell::new(); + +// Channels + ready via OnceLock (immutable access pattern) +static BATTERY_FUEL_READY: OnceLock = OnceLock::new(); + +static BATTERY_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static CHARGER_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static THERMAL_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static DISPLAY_EVENT_CHANNEL: OnceLock = OnceLock::new(); +static INTERACTION_EVENT_CHANNEL: OnceLock = OnceLock::new(); + +``` +We still want the BATTERY_FUEL_READY signal that we used in previous integrations. This tells us when the battery service is fully ready and we are safe to move ahead with other task activity. + +Even though we have chosen to unify the channel handling, we still want to create separate addressable channel wrappers that include their own unique Event types for sending. We define channels for each of our components, but also for the actions delegated to the Display and user input interaction. + +One may note that the `try_send` method of the channel wrapper code above does not handle errors particularly well. +It is assumed in this simple implementation that the channel is available and never fills up (capacity = 16). A more defensive strategy would check for back-pressure on the channel and throttle messaging appropriately. Keep this in mind when implementing real-world scenarios. + + +## Introducing the UI +At this point in the setup of the scaffolding we should introduce the elements that make up the User Interface portion of our simulation. +As we outlined at the beginning of this exercise, we are seeking to make this integration run as an interactive simulation, a non-interactive simulation, and an integration test as well. + +We will focus first on the simulation app aspects before considering our integration test implementation. + +To implement our UI, we introduce a `SystemObserver`, an intermediary between the simulation and the UI, including handling the rendering. + +Our rendering will assume two forms: We'll support a conventional "Logging" output that simply prints lines in sequence to the console as the values occur, because this is useful for analysis and debugging of behavior over time. But we will also support ANSI terminal cursor coding to support an "in-place" display that presents more of a dashboard view with changing values. This makes evaluation of the overall behavior and "feel" of our simulation and its behavior a little more approachable. + +Our simulation will also be interactive, allowing us to simulate increasing and decreasing load on the system, as one might experience during use of a typical laptop computer. + +So let's get to it. First, we'll define those values we wish to be displayed by the UI. + +Create the file `display_models.rs` and give it this content to start: + +```rust +#[derive(Clone, Debug)] +/// Static values that are displayed +pub struct StaticValues { + /// Battery manufacturer name + pub battery_mfr: String, + /// Battery model name + pub battery_name: String, + /// Battery chemistry type (e.g. LION) + pub battery_chem: String, + /// Battery serial number + pub battery_serial: String, + /// Battery designed mW capacity + pub battery_dsgn_cap_mwh: u32, + /// Battery designed mV capacity + pub battery_dsgn_voltage_mv: u16, +} +impl StaticValues { + pub fn new() -> Self { + Self { + battery_mfr: String::new(), + battery_name: String::new(), + battery_chem: String::new(), + battery_serial: String::new(), + battery_dsgn_cap_mwh: 0, + battery_dsgn_voltage_mv: 0, + } + } +} + +#[derive(Clone, Debug)] +/// Properties that are displayed by the renderer +pub struct DisplayValues { + /// Current running time of simulator (milliseconds) + pub sim_time_ms: f32, + /// Percent of State of Charge + pub soc_percent: f32, + /// battery/sensor temperature (Celsius) + pub temp_c: f32, + /// Fan Level (integer number 0-10) + pub fan_level: u8, + /// Fan level percentage + pub fan_percent: u8, + /// Fan running RPM + pub fan_rpm: u16, + + /// Current draw from system load (mA) + pub load_ma: u16, + /// Charger input (mA) + pub charger_ma: u16, + /// net difference to battery + pub net_batt_ma: i16, + + /// System draw in watts + pub draw_watts: f32, + /// System charge in watts + pub charge_watts: f32, + /// Net difference in watts + pub net_watts: f32 +} + +impl DisplayValues { + pub fn new() -> Self { + Self { + sim_time_ms: 0.0, + soc_percent: 0.0, + temp_c: 0.0, + fan_level: 0, + fan_percent: 0, + fan_rpm: 0, + + load_ma: 0, + charger_ma: 0, + net_batt_ma: 0, + draw_watts: 0.0, + charge_watts: 0.0, + net_watts: 0.0 + } + } +} +``` +This set of structures defines both the static and dynamic values of the system that will be tracked and displayed. + +These values pair up with the other configuration values we've already defined. + +Now let's move on with starting to build the scaffolding that supports all of this. diff --git a/guide_book/src/how/ec/integration/6-scaffold_start.md b/guide_book/src/how/ec/integration/6-scaffold_start.md new file mode 100644 index 0000000..4983831 --- /dev/null +++ b/guide_book/src/how/ec/integration/6-scaffold_start.md @@ -0,0 +1,71 @@ +# Scaffolding start-up +The `main()` function of our program immediately calls into the task `entry_task_interactive`, which is where the true entry to our integration app gets underway. + +We need to instantiate the various parts of our integration. This includes the parts that make up the scaffolding of the integration itself, such as the comm channels that carry component messages, and the parts responsible for display of data, and for user interaction. We did some of that in the previous step, in the first parts of `entry.rs`. + +The integration scaffolding of course also includes the integrated components themselves. The components will be managed together in a structure we will call `ComponentCore`. This core will be independent of the display and interaction mechanics, which will be handled primarily by a structure we will call `SystemObserver`. + +We will set about defining `ComponentCore` and `SystemObserver` shortly, but for now, we will concentrate on finishing out our basic scaffolding. + +## Shared items +We can group and share some of the common elements in a collection we will called `Shared`. This includes +the various comm `Channels` we have defined, and it will also hold the reference to our `SystemObserver` when we introduce that later. + +Add the following to `entry.rs`: + +```rust +// ---------- Shared handles for both modes ---------- +// Shared, Sync-clean. This can safely sit in a static OnceLock<&'static Shared>. +pub struct Shared { + // pub observer: &'static SystemObserver, + pub battery_channel: &'static BatteryChannelWrapper, + pub charger_channel: &'static ChargerChannelWrapper, + pub thermal_channel: &'static ThermalChannelWrapper, + pub display_channel: &'static DisplayChannelWrapper, + pub interaction_channel: &'static InteractionChannelWrapper, + pub battery_ready: &'static BatteryFuelReadySignal, + pub battery_fuel: &'static BatteryDevice, +} + +static SHARED_CELL: StaticCell = StaticCell::new(); +static SHARED: OnceLock<&'static Shared> = OnceLock::new(); + +fn init_shared() -> &'static Shared { + // Channels + ready + let battery_channel = BATTERY_EVENT_CHANNEL.get_or_init(|| BatteryChannelWrapper(Channel::new())); + let charger_channel = CHARGER_EVENT_CHANNEL.get_or_init(|| ChargerChannelWrapper(Channel::new())); + let thermal_channel = THERMAL_EVENT_CHANNEL.get_or_init(|| ThermalChannelWrapper(Channel::new())); + let display_channel = DISPLAY_EVENT_CHANNEL.get_or_init(|| DisplayChannelWrapper(Channel::new())); + let interaction_channel = INTERACTION_EVENT_CHANNEL.get_or_init(|| InteractionChannelWrapper(Channel::new())); + let battery_ready = BATTERY_FUEL_READY.get_or_init(|| BatteryFuelReadySignal::new()); + + let b =VirtualBatteryState::new_default(); + let v_nominal_mv = b.design_voltage_mv; + + // let observer = SYS_OBS.init(SystemObserver::new( + // Thresholds::new(), + // v_nominal_mv, + // display_channel + // )); + let battery_fuel = BATTERY_FUEL.init(BatteryDevice::new(BatteryServiceDeviceId(BATTERY_DEV_NUM))); + + SHARED.get_or_init(|| SHARED_CELL.init(Shared { + // observer, + battery_channel, + charger_channel, + thermal_channel, + display_channel, + interaction_channel, + battery_ready, + battery_fuel, + })) +} +``` + +Note the references to `observer` are commented out for now... we'll attach those in a later step. + +We'll continue the bootstrapping of our integration setup in the next step, where we will set up the components into our scaffolding. + + + + diff --git a/guide_book/src/how/ec/integration/7-setup_and_tap.md b/guide_book/src/how/ec/integration/7-setup_and_tap.md new file mode 100644 index 0000000..feb69c0 --- /dev/null +++ b/guide_book/src/how/ec/integration/7-setup_and_tap.md @@ -0,0 +1,205 @@ +# Setup and Tap +Before we can construct our `ControllerCore` structure, we need to have the allocations of the components ready. +We choose not to pass these around beyond constructing them into a single location, since we may run into borrow violations if we hand the references out too liberally, like we have seen in our previous integration attempts. + +This becomes even more complicated by the fact that when we commit our Battery Controller object to the battery service, we pass ownership to it -- and therefore lose access to our own construction. The solution here is to not give the battery service control of our Battery directly, but to give it a `BatteryAdapter` that looks like a battery, but instead simply forwards all of its actions to our `ControllerCore`. We call this "tapping" the service. In the `ControllerCore` we have access to not only our own battery, but also our charger and thermal components, so we can conduct our integration in a unified way. That said, we will still avoid tightly-coupled access between components as much as possible in favor of using messaging, because this pattern fosters better modularity. + +## In a view +The diagram below shows the ownership and message flow at a glance: + +```mermaid +flowchart LR + + %% --- UI --- + subgraph UI[UI] + direction TB + User[User] + Obs[SystemObserver] + Rend[DisplayRenderer] + end + + %% --- Channels --- + subgraph Channels[Channels] + direction TB + IChan[InteractionChannel] + DChan[DisplayChannel] + Bc[BatteryChannel] + Cc[ChargerChannel] + Tc[ThermalChannel] + end + + %% --- Service --- + subgraph Service[Service] + direction TB + W[Wrapper] --> A[BatteryAdapter] + end + + %% --- Core --- + subgraph Core[Core] + direction TB + CC[ControllerCore] + B[MockBatteryController] + C[MockChargerController] + S[MockSensorController] + F[MockFanController] + CC --> B & C & S & F + end + + %% --- Wiring --- + User --> IChan + IChan --> CC + + A --> CC + + CC --> Obs + Obs --> DChan + DChan --> Rend + + CC --> Bc + CC --> Cc + CC --> Tc +``` + +### The setup_and_tap code + +Create `setup_and_tap.rs` and give it this content to start: +```rust +use embassy_executor::Spawner; +use embassy_time::Duration; +use static_cell::StaticCell; +use embassy_sync::once_lock::OnceLock; +use ec_common::mutex::{Mutex, RawMutex}; + +use crate::entry::{Shared, BATTERY_DEV_NUM, CHARGER_DEV_NUM, SENSOR_DEV_NUM, FAN_DEV_NUM}; +use crate::controller_core::ControllerCore; + +use embedded_services::init; +use embedded_services::power::policy::register_device; +use embedded_services::power::policy::DeviceId; +use embedded_services::power::policy::device::Device as PolicyDevice; + +use mock_battery::mock_battery_device::MockBatteryDevice; +use mock_battery::mock_battery_controller::MockBatteryController; + +use mock_charger::mock_charger_device::MockChargerDevice; +use mock_charger::mock_charger_controller::MockChargerController; +use embedded_services::power::policy::charger::Device as ChargerDevice; // disambiguate from other device types +use embedded_services::power::policy::charger::ChargerId; + +use mock_thermal::mock_sensor_device::MockSensorDevice; +use mock_thermal::mock_fan_device::MockFanDevice; +use mock_thermal::mock_sensor_controller::MockSensorController; +use mock_thermal::mock_fan_controller::MockFanController; + +use battery_service::wrapper::Wrapper; + +use crate::battery_adapter::BatteryAdapter; + +// ---------- statics that must live for 'static tasks ---------- +static BATTERY_WRAPPER: StaticCell> = StaticCell::new(); + +static BATTERY_DEVICE: StaticCell = StaticCell::new(); +static BATTERY_POLICY_DEVICE: StaticCell = StaticCell::new(); + +static CHARGER_DEVICE: StaticCell = StaticCell::new(); +static CHARGER_POLICY_DEVICE: StaticCell = StaticCell::new(); +static CHARGER_SERVICE_DEVICE: OnceLock = OnceLock::new(); + + +static SENSOR_DEVICE: StaticCell = StaticCell::new(); +static SENSOR_POLICY_DEVICE: StaticCell = StaticCell::new(); +static FAN_DEVICE: StaticCell = StaticCell::new(); +static FAN_POLICY_DEVICE: StaticCell = StaticCell::new(); + +/// Initialize registration of all the integration components +#[embassy_executor::task] +pub async fn setup_and_tap_task(spawner: Spawner, shared: &'static Shared) { + println!("⚙️ Initializing embedded-services"); + init().await; + + println!("⚙️ Spawning battery service task"); + spawner.spawn(battery_service::task()).unwrap(); + + // ----------------- Device/controller construction ----------------- + let battery_dev = BATTERY_DEVICE.init(MockBatteryDevice::new(DeviceId(BATTERY_DEV_NUM))); + let battery_policy_dev = BATTERY_POLICY_DEVICE.init(PolicyDevice::new(DeviceId(BATTERY_DEV_NUM))); + + // Build the battery controller locally and MOVE it into the wrapper below. + // (No StaticCell needed for the controller since the wrapper will own it.) + let battery_controller = MockBatteryController::new(battery_dev); + + // Similar for others, although they are not moved into wrapper + let charger_dev = CHARGER_DEVICE.init(MockChargerDevice::new(DeviceId(CHARGER_DEV_NUM))); + let charger_policy_dev = CHARGER_POLICY_DEVICE.init(MockChargerDevice::new(DeviceId(CHARGER_DEV_NUM))); + let charger_controller = MockChargerController::new(charger_dev); + + + // Thermal (controllers own their devices) + let sensor_dev = SENSOR_DEVICE.init(MockSensorDevice::new(DeviceId(SENSOR_DEV_NUM))); + let sensor_policy_dev = SENSOR_POLICY_DEVICE.init(MockSensorDevice::new(DeviceId(SENSOR_DEV_NUM))); + let fan_dev = FAN_DEVICE.init(MockFanDevice::new(DeviceId(FAN_DEV_NUM))); + let fan_policy_dev = FAN_POLICY_DEVICE.init(MockFanDevice::new(DeviceId(FAN_DEV_NUM))); + let sensor_controller = MockSensorController::new(sensor_dev); + let fan_controller = MockFanController::new(fan_dev); + + let charger_service_device: &'static ChargerDevice = CHARGER_SERVICE_DEVICE.get_or_init(|| ChargerDevice::new(ChargerId(CHARGER_DEV_NUM))); + + // Then use these to create our ControllerTap handler, which isolates ownership of all but the battery, which is + // owned by the Wrapper. We can access the other "real" controllers upon battery message receipts by the Tap. + // We must still stick to message passing to communicate between components to preserve modularity. + let controller_core = ControllerCore::new( + battery_controller, charger_controller, sensor_controller, fan_controller, + charger_service_device, + shared.battery_channel,shared.charger_channel,shared.thermal_channel,shared.interaction_channel, + shared.observer, + ); + + static TAP_CELL: StaticCell> = StaticCell::new(); + let core_mutex: &'static Mutex = TAP_CELL.init(Mutex::new(controller_core)); + let battery_adapter = BatteryAdapter::new(core_mutex); + + + // ----------------- Battery wrapper ----------------- + println!("⚙️ Spawning battery wrapper task"); + let wrapper = BATTERY_WRAPPER.init(Wrapper::new( + shared.battery_fuel, // &'static BatteryDevice, provided by Instances + battery_adapter // move ownership into the wrapper + )); + spawner.spawn(battery_wrapper_task(wrapper)).unwrap(); + + // Registrations + println!("🧩 Registering battery device..."); + register_device(battery_policy_dev).await.unwrap(); + + println!("🧩 Registering charger device..."); + register_device(charger_policy_dev).await.unwrap(); + + println!("🧩 Registering sensor device..."); + register_device(sensor_policy_dev).await.unwrap(); + + println!("🧩 Registering fan device..."); + register_device(fan_policy_dev).await.unwrap(); + + // ----------------- Fuel gauge / ready ----------------- + println!("🔌 Initializing battery fuel gauge service..."); + battery_service::register_fuel_gauge(&shared.battery_fuel).await.unwrap(); + + spawner.spawn(battery_start_task()).unwrap(); + + // signal that the battery fuel service is ready + shared.battery_ready.signal(); + + println!("Setup and Tap calling ControllerCore::start..."); + ControllerCore::start(core_mutex, spawner); + +} +``` +This starts out by allocating and creating the components that we will need, starting with the aforementioned `BatteryAdapter`, which we will implement in a moment, and creating the `BatteryWrapper` with this in mind. + +It then creates the battery, charger, sensor, and fan components. You may notice that in doing so we create both a DEVICE and a POLICY_DEVICE for each. Both of these Device type wrappers are identical per component. One is used to create the controller, and one is used to register the device with the service. Since these are tied by Id designation, they are equivalent, and since we can't pass a single instance twice without incurring a borrow violation, we use this technique. + +This brings us to the construction of the `ControllerCore`. Here, we give it all of the components, plus the comm channels that were shared from our earlier allocations in `entry.rs`. We also see here we are passing references to a new channel `integration_channel`, and the `SystemObserver`, neither of which we have created yet. + +Once we get our `ControllerCore` instance created, we wrap it into a mutex that we stash into a `StaticCell` so that we have portable access to this structure. + +The remainder of the `setup_and_tap_task` proceeds with registration and then spawning the execution tasks. diff --git a/guide_book/src/how/ec/integration/8-battery_adapter.md b/guide_book/src/how/ec/integration/8-battery_adapter.md new file mode 100644 index 0000000..2599b61 --- /dev/null +++ b/guide_book/src/how/ec/integration/8-battery_adapter.md @@ -0,0 +1,306 @@ +# Battery Adapter + +The battery service expects to be handed a type that implements the `SmartBattery` trait, as well as the `Controller` trait defined in the `battery_service::controller` module. We can create a simple adapter type that holds a reference to our `ControllerCore` mutex, and then forwards the trait method calls into the core controller code. + +```rust +use crate::controller_core::ControllerCore; +#[allow(unused_imports)] +use ec_common::mutex::{RawMutex, Mutex}; +use core::sync::atomic::{AtomicU64, Ordering}; + +#[allow(unused_imports)] + use battery_service::controller::{Controller, ControllerEvent}; +use battery_service::device::{DynamicBatteryMsgs, StaticBatteryMsgs}; +use embassy_time::Duration; +use mock_battery::mock_battery::MockBatteryError; +#[allow(unused_imports)] + use embedded_batteries_async::smart_battery::{ + SmartBattery, + ManufactureDate, SpecificationInfoFields, CapacityModeValue, CapacityModeSignedValue, + BatteryModeFields, BatteryStatusFields, + DeciKelvin, MilliVolts + }; + +const DEFAULT_TIMEOUT_MS: u64 = 1000; + +#[allow(unused)] + pub struct BatteryAdapter { + core_mutex: &'static Mutex, + timeout_ms: AtomicU64 // cached timeout to work around sync/async mismatch + } + + impl BatteryAdapter { +#[allow(unused)] + pub fn new(core_mutex: &'static Mutex) -> Self { + Self { + core_mutex, + timeout_ms: AtomicU64::new(DEFAULT_TIMEOUT_MS) + } + } + + #[inline] + fn dur_to_ms(d: Duration) -> u64 { + // Use the unit that’s most convenient for you; ms is usually fine. + d.as_millis() as u64 + } + + #[inline] + fn ms_to_dur(ms: u64) -> Duration { + Duration::from_millis(ms as u64) + } + + // called on Controller methods to shadow timeout value we can forward in a synchronous trait method + fn sync_timeout_cache(&self, core: &mut ControllerCore) { + use core::sync::atomic::Ordering; + let cached = self.timeout_ms.load(Ordering::Relaxed); + let current = Self::dur_to_ms(core.get_timeout()); + if current != cached { + core.set_timeout(Self::ms_to_dur(cached)); + } + } + + } + +impl embedded_batteries_async::smart_battery::ErrorType for BatteryAdapter +{ + type Error = MockBatteryError; +} + + impl SmartBattery for BatteryAdapter { + async fn temperature(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.temperature().await + } + + async fn voltage(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.voltage().await + } + + async fn remaining_capacity_alarm(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.remaining_capacity_alarm().await + } + + async fn set_remaining_capacity_alarm(&mut self, v: CapacityModeValue) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_remaining_capacity_alarm(v).await + } + + async fn remaining_time_alarm(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.remaining_time_alarm().await + } + + async fn set_remaining_time_alarm(&mut self, v: u16) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_remaining_time_alarm(v).await + } + + async fn battery_mode(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.battery_mode().await + } + + async fn set_battery_mode(&mut self, v: BatteryModeFields) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_battery_mode(v).await + } + + async fn at_rate(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate().await + } + + async fn set_at_rate(&mut self, _: CapacityModeSignedValue) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.set_at_rate(CapacityModeSignedValue::MilliAmpSigned(0)).await + } + + async fn at_rate_time_to_full(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate_time_to_full().await + } + + async fn at_rate_time_to_empty(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate_time_to_empty().await + } + + async fn at_rate_ok(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.at_rate_ok().await + } + + async fn current(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.current().await + } + + async fn average_current(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.average_current().await + } + + async fn max_error(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.max_error().await + } + + async fn relative_state_of_charge(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.relative_state_of_charge().await + } + + async fn absolute_state_of_charge(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.absolute_state_of_charge().await + } + + async fn remaining_capacity(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.remaining_capacity().await + } + + async fn full_charge_capacity(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.full_charge_capacity().await + } + + async fn run_time_to_empty(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.run_time_to_empty().await + } + + async fn average_time_to_empty(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.average_time_to_empty().await + } + + async fn average_time_to_full(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.average_time_to_full().await + } + + async fn charging_current(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.charging_current().await + } + + async fn charging_voltage(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.charging_voltage().await + } + + async fn battery_status(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.battery_status().await + } + + async fn cycle_count(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.cycle_count().await + } + + async fn design_capacity(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.design_capacity().await + } + + async fn design_voltage(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.design_voltage().await + } + + async fn specification_info(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.specification_info().await + } + + async fn manufacture_date(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.manufacture_date().await + } + + async fn serial_number(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + c.serial_number().await + } + + async fn manufacturer_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.manufacturer_name(v).await + } + + async fn device_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.device_name(v).await + } + + async fn device_chemistry(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { + let mut c = self.core_mutex.lock().await; + c.device_chemistry(v).await + } + } + +impl Controller for BatteryAdapter { + + type ControllerError = MockBatteryError; + + async fn initialize(&mut self) -> Result<(), Self::ControllerError> { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.initialize().await + } + + + async fn get_static_data(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.get_static_data().await + } + + async fn get_dynamic_data(&mut self) -> Result { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.get_dynamic_data().await + } + + async fn get_device_event(&mut self) -> ControllerEvent { + core::future::pending().await + } + + async fn ping(&mut self) -> Result<(), Self::ControllerError> { + let mut c = self.core_mutex.lock().await; + self.sync_timeout_cache(&mut *c); // deref + ref safely casts away the guard + c.ping().await + } + + fn get_timeout(&self) -> Duration { + // Fast path: if we can grab the mutex without waiting, read the real value. + if let Ok(guard) = self.core_mutex.try_lock() { + let d = guard.get_timeout(); // assumed non-async on core + self.timeout_ms.store(Self::dur_to_ms(d), Ordering::Relaxed); + d + } else { + // Fallback to cached value if the mutex is busy. + Self::ms_to_dur(self.timeout_ms.load(Ordering::Relaxed)) + } + } + + fn set_timeout(&mut self, duration: Duration) { + // Always update our cache immediately. + self.timeout_ms.store(Self::dur_to_ms(duration), Ordering::Relaxed); + + // Try to apply to the real controller right away if the mutex is free. + // if the mutex is busy, we'll simply use the previous cache next time. + if let Ok(mut guard) = self.core_mutex.try_lock() { + guard.set_timeout(duration); // assumed non-async on core + } + + + } +} +``` +As noted, the `BatteryAdapter` is nothing more than a forwarding mechanism to direct the trait methods called by the battery service into our code base. We pass it the reference to our `core_mutex` which is then used to call the battery controller traits implemented there, in our `ControllerCore` code. + diff --git a/guide_book/src/how/ec/integration/9-system_observer.md b/guide_book/src/how/ec/integration/9-system_observer.md new file mode 100644 index 0000000..ccdea0d --- /dev/null +++ b/guide_book/src/how/ec/integration/9-system_observer.md @@ -0,0 +1,308 @@ + +# The SystemObserver + +Before we can construct our `ControllerCore`, we still need a `SystemObserver` and and `InteractionChannelWrapper` to be defined. + +The `SystemObserver` is the conduit to display output and communicates with a `DisplayRenderer` used to portray output in various ways. The renderer itself is message-driven, as are user interaction events, so we will start by going back into `entry.rs` and adding both the `DisplayChannelWrapper` and `InteractionChannelWrapper` beneath the other "Channel Wrapper" definitions for Battery, Charger, and Thermal communication. +```rust +pub struct DisplayChannelWrapper(pub Channel); +#[allow(unused)] +impl DisplayChannelWrapper { + pub async fn send(&self, e: DisplayEvent) { self.0.send(e).await } + pub async fn receive(&self) -> DisplayEvent { self.0.receive().await } +} +impl EventChannel for DisplayChannelWrapper { + type Event = DisplayEvent; + fn try_send(&self, e: DisplayEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +pub struct InteractionChannelWrapper(pub Channel); +impl InteractionChannelWrapper { + pub async fn send(&self, e: InteractionEvent) { self.0.send(e).await } + pub async fn receive(&self) -> InteractionEvent { self.0.receive().await } +} +impl EventChannel for InteractionChannelWrapper { + type Event = InteractionEvent; + fn try_send(&self, e: InteractionEvent) -> Result<(), MailboxDelegateError> { + self.0.try_send(e).map_err(|_| MailboxDelegateError::MessageNotFound) + } +} +``` + +Now, let's create `system_observer.rs` and give it this content to start: +```rust +use crate::events::{DisplayEvent}; +use crate::display_models::{DisplayValues, StaticValues, InteractionValues, Thresholds}; +use crate::entry::DisplayChannelWrapper; +use embassy_time::{Instant, Duration}; +use ec_common::mutex::{Mutex, RawMutex}; + +struct ObserverState { + sv: StaticValues, // static values + dv: DisplayValues, // current working frame + last_sent: DisplayValues, // last emitted frame + last_emit_at: Instant, + first_emit: bool, + interaction: InteractionValues, + last_speed_number: u8 +} + +pub struct SystemObserver { + state: Mutex, + thresholds: Thresholds, + v_nominal_mv: u16, + min_emit_interval: Duration, + display_tx: &'static DisplayChannelWrapper, +} +impl SystemObserver { + pub fn new(thresholds: Thresholds, v_nominal_mv: u16, display_tx: &'static DisplayChannelWrapper) -> Self { + let now = Instant::now(); + Self { + state: Mutex::new(ObserverState { + sv: StaticValues::new(), + dv: DisplayValues::new(), // default starting values + last_sent: DisplayValues::new(), // default baseline + last_emit_at: now, + first_emit: true, + interaction: InteractionValues::default(), + last_speed_number: 0 + }), + thresholds, + v_nominal_mv, + min_emit_interval: Duration::from_millis(100), + display_tx, + } + } + + pub async fn increase_load(&self) { + let mut guard = self.state.lock().await; + guard.interaction.increase_load(); + } + pub async fn decrease_load(&self) { + let mut guard = self.state.lock().await; + guard.interaction.decrease_load(); + } + pub async fn set_speed_number(&self, speed_num: u8) { + let mut guard = self.state.lock().await; + guard.interaction.set_speed_number(speed_num); + } + pub async fn interaction_snapshot(&self) -> InteractionValues { + let guard = self.state.lock().await; + guard.interaction + } + + pub async fn toggle_mode(&self) { + let mut guard = self.state.lock().await; + guard.last_emit_at = Instant::now(); + guard.first_emit = true; + self.display_tx.send(DisplayEvent::ToggleMode).await; + } + pub async fn quit(&self) { + self.display_tx.send(DisplayEvent::Quit).await; + } + + pub async fn set_static(&self, new_sv: StaticValues) { + let mut guard = self.state.lock().await; + guard.sv = new_sv; + self.display_tx.send(DisplayEvent::Static(guard.sv.clone())).await; + } + + /// Full-frame update from ControllerCore + pub async fn update(&self, mut new_dv: DisplayValues, ia: InteractionValues) { + // Derive any secondary numbers in one place (keeps UI/logs consistent). + derive_power(&mut new_dv, self.v_nominal_mv); + + let mut guard = self.state.lock().await; + guard.dv = new_dv; + + let now = Instant::now(); + let should_emit = + guard.first_emit || + (ia.sim_speed_number != guard.last_speed_number) || + (now - guard.last_emit_at >= self.min_emit_interval && + diff_exceeds(&guard.dv, &guard.last_sent, &self.thresholds)); + + if should_emit { + self.display_tx.send(DisplayEvent::Update(guard.dv.clone(), ia)).await; + guard.last_sent = guard.dv.clone(); + guard.last_emit_at = now; + guard.first_emit = false; + guard.last_speed_number = ia.sim_speed_number; + } + } +} + +// ------- helpers (keep them shared so renderers never recompute differently) ------- +fn derive_power(dv: &mut DisplayValues, v_nominal_mv: u16) { + let draw_w = (dv.load_ma as i32 * v_nominal_mv as i32) as f32 / 1_000_000.0; + let charge_w = (dv.charger_ma as i32 * v_nominal_mv as i32) as f32 / 1_000_000.0; + dv.draw_watts = ((draw_w * 10.0).round()) / 10.0; + dv.charge_watts = ((charge_w * 10.0).round()) / 10.0; + dv.net_watts = (( (dv.charge_watts - dv.draw_watts) * 10.0).round()) / 10.0; + dv.net_batt_ma = dv.charger_ma as i16 - dv.load_ma as i16; +} + +fn diff_exceeds(cur: &DisplayValues, prev: &DisplayValues, th: &Thresholds) -> bool { + (cur.draw_watts - prev.draw_watts).abs() >= th.load_w_delta || + (cur.soc_percent - prev.soc_percent).abs() >= th.soc_pct_delta || + (cur.temp_c - prev.temp_c).abs() >= th.temp_c_delta || + (th.on_fan_change && cur.fan_level != prev.fan_level) +} +``` + +and to fill this out, we need to add to our `display_models.rs` file to define values used for Interaction and to define threshold ranges for the display. + +Add these definitions to `display__models.rs`: +```rust +#[allow(unused)] +#[derive(Clone, Copy)] +/// thresholds of change to warrant a display update +pub struct Thresholds { + /// minimum load change to report + /// e.g., 0.2 W + pub load_w_delta: f32, + /// minimum soc change to report + /// e.g., 0.5 % + pub soc_pct_delta: f32, + /// minimum temperature change to report + /// e.g., 0.2 °C + pub temp_c_delta: f32, + /// report if fan changes. + /// `true`` to update display if fan state changes + pub on_fan_change: bool, + + /// maximum wattage we can draw from system + pub max_load: f32, + /// warning we are getting hot + pub warning_temp: f32, + /// we are too hot + pub danger_temp: f32, + /// soc % is getting low + pub warning_charge: f32, + /// soc % is too low.. power fail imminent + pub danger_charge: f32, +} +impl Thresholds { + pub fn new() -> Self { + Self { + load_w_delta: 0.5, + soc_pct_delta: 0.1, + temp_c_delta: 0.5, + on_fan_change: true, + + max_load: 100.0, // 100W peak draw + warning_temp: 28.0, // 28 deg C (82.4 F) + danger_temp: 34.0, // 34 deg C (93.2 F) + warning_charge: 20.0, // <20% remaining + danger_charge: 8.0, // <8% remaining + } + } +} +#[derive(Debug, Clone, Copy)] +pub struct InteractionValues { + pub system_load: u16, + pub sim_speed_number: u8, + pub sim_speed_multiplier: f32 +} +const LOAD_INCREMENT: u16 = 100; // mA +const LOAD_MIN: u16 = 0; +const LOAD_MAX: u16 = 5000; + +const SPEED_SETTING : [u8; 5] = [1, 10, 25, 50, 100]; + +impl InteractionValues { + pub fn increase_load(&mut self) { + self.system_load = clamp_load(self.system_load.saturating_add(LOAD_INCREMENT)); + } + pub fn decrease_load(&mut self) { + self.system_load = clamp_load(self.system_load.saturating_sub(LOAD_INCREMENT)); + } + pub fn set_speed_number(&mut self, mut num:u8) { + if num < 1 { num = 1;} + if num > 5 { num = 5;} + self.sim_speed_number = num; + let idx:usize = num as usize -1; + self.sim_speed_multiplier = SPEED_SETTING[idx] as f32; + } + pub fn get_speed_number_and_multiplier(&self) -> (u8, f32) { + (self.sim_speed_number, self.sim_speed_multiplier) + } +} + +impl Default for InteractionValues { + fn default() -> Self { + Self { + system_load: 1200, + sim_speed_number: 3, + sim_speed_multiplier: 25.0 + } + } +} + +//-- helper functions +#[inline] +fn clamp_load(v: u16) -> u16 { + v.clamp(LOAD_MIN, LOAD_MAX) +} + +/// Power/units helpers for consistent display & logs. +/// +/// Conventions: +/// - Currents are mA (signed where net flow can be negative). +/// - Voltages are mV. +/// - Watts are f32, rounded for display to 0.1 W. +/// - Positive current into the system is "charger input"; positive load is "system draw". +/// - Net battery current = charger_ma - load_ma (mA). +/// - Net watts = charge_watts - draw_watts (W). +#[inline] +pub fn mw_from_ma_mv(ma: i32, mv: u16) -> i32 { + // exact integer math in mW to avoid float jitter for logs + (ma as i64 * mv as i64 / 1000) as i32 +} + +#[inline] +pub fn w_from_ma_mv(ma: i32, mv: u16) -> f32 { + // convenience for UI (single rounding site) + mw_from_ma_mv(ma, mv) as f32 / 1000.0 +} + +#[inline] +pub fn round_w_01(w: f32) -> f32 { + (w * 10.0).round() / 10.0 +} +``` + +and these definitions to `events.rs`: +```rust +use crate::display_models::{StaticValues, DisplayValues, InteractionValues}; + + +#[allow(unused)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RenderMode { + InPlace, // ANSI Terminal application + Log // line-based console output +} + +#[allow(unused)] +#[derive(Debug)] +pub enum DisplayEvent { + Update(DisplayValues, InteractionValues), // observer pushes new values to renderer + Static(StaticValues), // observer pushes new static values to renderer + ToggleMode, // switch between Log and InPlace RenderMode (forwarded from interactin) + Quit, // exit simulation (forwarded from interaction) +} + +#[allow(unused)] +#[derive(Debug)] +pub enum InteractionEvent { + LoadUp, // increase system load + LoadDown, // decrease system load + TimeSpeed(u8), // set time multiplier via speed number + ToggleMode, // switch between Log and InPlace RenderMode (forward to Display) + Quit, // exit simulation (forward to Display) +} +``` + +For now, we only need to provide `SystemObserver` and related structures as dependencies to the system so that we can construct a minimal standup for our first tests. We'll outfit it with the Display and Interaction features later. diff --git a/guide_book/src/how/ec/integration/integration_tests.md b/guide_book/src/how/ec/integration/integration_tests.md deleted file mode 100644 index 206b406..0000000 --- a/guide_book/src/how/ec/integration/integration_tests.md +++ /dev/null @@ -1,3 +0,0 @@ -# Integration Tests -_TODO: The tests of the full integration_ - diff --git a/guide_book/src/how/ec/integration/media/integration-sim.png b/guide_book/src/how/ec/integration/media/integration-sim.png new file mode 100644 index 0000000000000000000000000000000000000000..1701f0cd8ea4a93ebea7c0b00d2c5d50382ea846 GIT binary patch literal 26264 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 Date: Thu, 25 Sep 2025 11:55:08 -0700 Subject: [PATCH 5/6] revisions per comments --- .../src/how/ec/integration/1-integration.md | 12 + .../how/ec/integration/10-controller_core.md | 34 +- .../src/how/ec/integration/11-first_tests.md | 11 +- .../ec/integration/12-tasks_and_listeners.md | 43 +- .../ec/integration/14-display_rendering.md | 77 +-- .../how/ec/integration/16-in_place_render.md | 4 +- .../how/ec/integration/19-meaningful_tests.md | 18 +- .../src/how/ec/integration/2-move_events.md | 246 +++----- .../ec/integration/20-charger_attachment.md | 12 +- .../how/ec/integration/21-affecting_change.md | 39 +- .../how/ec/integration/4-update_controller.md | 537 ++++++++++++++++-- .../how/ec/integration/5-structural_steps.md | 7 +- .../src/how/ec/integration/7-setup_and_tap.md | 154 ++++- .../how/ec/integration/8-battery_adapter.md | 3 + .../how/ec/integration/9-system_observer.md | 4 +- 15 files changed, 817 insertions(+), 384 deletions(-) diff --git a/guide_book/src/how/ec/integration/1-integration.md b/guide_book/src/how/ec/integration/1-integration.md index 7521d9b..17f5a6f 100644 --- a/guide_book/src/how/ec/integration/1-integration.md +++ b/guide_book/src/how/ec/integration/1-integration.md @@ -66,6 +66,9 @@ embassy-time-queue-utils = { workspace = true } embedded-services = { workspace = true } battery-service = { workspace = true } embedded-sensors-hal-async = {workspace = true} +embedded-fans-async = {workspace = true} +thermal-service = {workspace = true} + ec_common = { path = "../ec_common"} mock_battery = { path = "../battery_project/mock_battery", default-features = false} @@ -105,6 +108,12 @@ Next, edit the `ec_examples/Cargo.toml` at the top level to add `integration_pro ] ``` +We also need to add a couple more references to the `[patch.crates-io]` section to ensure cargo refers to the same crates across the board: +```toml +embedded-sensors-hal-async = { path = "embedded-sensors/embedded-sensors-async"} +embedded-fans-async = { path = "embedded-fans/embedded-fans-async"} +``` + _As a reminder, the whole of `ec_examples/Cargo.toml` looks like this:_ ```toml @@ -117,6 +126,7 @@ members = [ "thermal_project/mock_thermal", "battery_charger_subsystem", "integration_project", + "target-integration_project", "ec_common" ] @@ -174,6 +184,8 @@ embassy-futures = { path = "embassy/embassy-futures" } embassy-time-driver = { path = "embassy/embassy-time-driver" } embassy-time-queue-utils = { path = "embassy/embassy-time-queue-utils" } embedded-batteries-async = { path = "embedded-batteries/embedded-batteries-async" } +embedded-sensors-hal-async = { path = "embedded-sensors/embedded-sensors-async"} +embedded-fans-async = { path = "embedded-fans/embedded-fans-async"} # Lint settings for the entire workspace. # We start with basic warning visibility, especially for upcoming Rust changes. diff --git a/guide_book/src/how/ec/integration/10-controller_core.md b/guide_book/src/how/ec/integration/10-controller_core.md index e89a821..1a4645a 100644 --- a/guide_book/src/how/ec/integration/10-controller_core.md +++ b/guide_book/src/how/ec/integration/10-controller_core.md @@ -11,12 +11,10 @@ Our `ControllerCore` implementation will consist of four primary areas of concer The first two of these are necessary to implement in order to create a minimally viable first test. -Let's start out with the basic implementation of `contoller_core.rs` by starting with this code: +Let's start out with the basic implementation of `controller_core.rs` by starting with this code: ```rust use mock_battery::mock_battery_controller::MockBatteryController; use mock_charger::mock_charger_controller::MockChargerController; -use mock_thermal::mock_sensor_controller::MockSensorController; -use mock_thermal::mock_fan_controller::MockFanController; use crate::config::ui_config::RenderMode; use crate::system_observer::SystemObserver; use crate::entry::{BatteryChannelWrapper, ChargerChannelWrapper, InteractionChannelWrapper, ThermalChannelWrapper}; @@ -37,12 +35,10 @@ use embedded_services::power::policy::PowerCapability; use embedded_services::power::policy::charger::PolicyEvent; use embedded_services::power::policy::charger::ChargerResponseData; -use embedded_sensors_hal_async::temperature::TemperatureThresholdSet; - use ec_common::mutex::{Mutex, RawMutex}; use crate::display_models::StaticValues; use crate::events::{BusEvent, InteractionEvent}; -use ec_common::events::{ThermalEvent, ThresholdEvent}; +use ec_common::events::ThermalEvent; use embedded_services::power::policy::charger::{ChargerEvent, PsuState}; use embassy_sync::channel::{Channel, Sender, Receiver, TrySendError}; @@ -57,15 +53,23 @@ use mock_charger::mock_charger::MockChargerError; const BUS_CAP: usize = 32; use crate::config::AllConfig; -use crate::state::{ChargerState, ThermalState, SimState}; +use crate::state::{ChargerState, SimState}; + +use crate::setup_and_tap::INTERNAL_SAMPLE_BUF_LENGTH; + +use thermal_service as ts; +use ts::sensor as tss; +use ts::fan as tsf; + #[allow(unused)] pub struct ControllerCore { // device components pub battery: MockBatteryController, // controller tap is owned by battery service wrapper pub charger: MockChargerController, - pub sensor: MockSensorController, - pub fan: MockFanController, + // ODP wrappers, not raw controllers + pub sensor: &'static tss::Sensor, + pub fan: &'static tsf::Fan, // for charger service pub charger_service_device: &'static ChargerDevice, @@ -85,7 +89,6 @@ pub struct ControllerCore { // state pub sim: SimState, - pub therm: ThermalState, pub chg: ChargerState } @@ -96,8 +99,8 @@ impl ControllerCore { pub fn new( battery: MockBatteryController, charger: MockChargerController, - sensor: MockSensorController, - fan: MockFanController, + sensor: &'static tss::Sensor, + fan: &'static tsf::Fan, charger_service_device: &'static ChargerDevice, battery_channel: &'static BatteryChannelWrapper, charger_channel: &'static ChargerChannelWrapper, @@ -114,7 +117,6 @@ impl ControllerCore { sysobs, cfg: AllConfig::default(), sim: SimState::default(), - therm: ThermalState::default(), chg: ChargerState::default() } } @@ -135,15 +137,17 @@ impl ControllerCore { /// start event processing with a passed mutex pub fn start(core_mutex: &'static Mutex, spawner: Spawner) { - println!("In ControllerCore::start (fn={:p})", Self::start as *const ()); + println!("In ControllerCore::start()"); } } ``` Now, you will recall that we created `BatteryAdapter` as a structure implementing all the traits required for it to serve as the component registered for the Battery Service (via the `BatteryWrapper`), and that this implementation simply passed these traits along to this `ControllerCore` instance, so we must necessarily implement all those trait methods here in `ControllerCore` as well. Since we have our actual Battery object contained here, we can forward these in turn to that component, thus attaching it to the Battery Service. But along the way, we get the opportunity to "tap into" this relay and use this opportunity to conduct our integration business. +As noted briefly earlier, we could choose to not implement these traits here. In the end the `ControllerCore` does not really need to "look like a battery controller" -- it just needs to handle key methods triggered by the tr + Let's go ahead and implement these traits by adding this code to `controller_core.rs` now. -This looks long, but most of it is just pass-through to the underlying battery and charger components (remember how extensive the `SmartBatter`y traits are): +This looks long, but most of it is just pass-through to the underlying battery and charger components (remember how extensive the `SmartBattery` traits are): ```rust // ================= traits ================== diff --git a/guide_book/src/how/ec/integration/11-first_tests.md b/guide_book/src/how/ec/integration/11-first_tests.md index c012355..a086020 100644 --- a/guide_book/src/how/ec/integration/11-first_tests.md +++ b/guide_book/src/how/ec/integration/11-first_tests.md @@ -12,9 +12,8 @@ use crate::system_observer::SystemObserver; // use crate::display_render::display_render::DisplayRenderer; // Task imports -use crate::setup_and_tap::{ - setup_and_tap_task -}; +use crate::setup_and_tap::setup_and_tap_task; + ``` And now we want to add the entry point that is called by `main()` here (in `entry.rs`): ```rust @@ -149,8 +148,8 @@ setup_and_tap_starting 🥳 >>>>> get_timeout has been called!!! <<<<<< 🧩 Registering battery device... 🧩 Registering charger device... -🧩 Registering sensor device... -🧩 Registering fan device... +🧩 Registering sensor device to thermal service... +🧩 Registering fan device to thermal service... 🔌 Initializing battery fuel gauge service... Setup and Tap calling ControllerCore::start... In ControllerCore::start (fn=0x7ff6425f9860) @@ -181,7 +180,7 @@ Let's pause here to review what is actually happening at this point. ### Review of operation so far 1. `main()` calls `entry_task_interactive()`, which initializes the shared handles and spawns the `setup_and_tap_task()`. -2. `setup_and_tap_task()` initializes embedded-services, spawns the battery service task, constructs and registers the mock devices and controllers, and finally spawns the `ControllerCore::start()` task. +2. `setup_and_tap_task()` initializes embedded-services, and thermal-services, spawns the battery service task, constructs and registers the mock devices and controllers, registers the thermal components, and finally spawns the `ControllerCore::start()` task. 3. `ControllerCore::start()` initializes the controller core, spawns the charger policy event task, the controller core task, the start charger task, and the integration listener task. 4. Meanwhile, back in `entry_task_interactive()`, after spawning `setup_and_tap_task()`, it waits for the battery fuel service to signal that it is ready, which happens at the end of `setup_and_tap_task()`. 5. The `battery_start_task()` is spawned as part of `setup_and_tap_task ()`, which initializes the battery service by sending it a `DoInit` event, followed by a `PollStaticData` event, and then enters a loop where it continuously sends `PollDynamicData` events to the battery service at regular intervals. This is what drives the periodic updates of battery data in our integration. diff --git a/guide_book/src/how/ec/integration/12-tasks_and_listeners.md b/guide_book/src/how/ec/integration/12-tasks_and_listeners.md index b6b60c3..a01aa57 100644 --- a/guide_book/src/how/ec/integration/12-tasks_and_listeners.md +++ b/guide_book/src/how/ec/integration/12-tasks_and_listeners.md @@ -116,25 +116,12 @@ async fn handle_thermal(core_mutex: &'static Mutex, ev ThermalEvent::TempSampleC100(cc) => { let temp_c = cc as f32 / 100.0; { - let mut core = core_mutex.lock().await; - core.sensor.sensor.set_temperature(temp_c); - } - } - - ThermalEvent::Threshold(th) => { - match th { - ThresholdEvent::OverHigh => println!(" ⚠🔥 running hot"), - _ => {} + let core = core_mutex.lock().await; + let mut ctrl = core.sensor.controller().lock().await; + ctrl.set_sim_temp(temp_c); } - } - - ThermalEvent::CoolingRequest(req) => { - let mut core = core_mutex.lock().await; - let policy = core.cfg.policy.thermal.fan_policy; - let cur_level = core.therm.fan_level; - let (res, _rpm) = core.fan.handle_request(cur_level, req, &policy).await.unwrap(); - core.therm.fan_level = res.new_level; - } + }, + _ => {} } } ``` @@ -207,9 +194,7 @@ pub async fn charger_policy_event_task(core_mutex: &'static Mutex5.1}% | Draw {:>5.1}W | Chg {:>5.1}W | Net {:>+5.1}W | T {:>4.1}°C | Fan L{} {}% {}rpm", + "[{}]({} {}) {} - SOC {:>5.1}% | Draw {:>5.1}W | Chg {:>5.1}W | Net {:>+5.1}W | T {:>4.1}°C | Fan: {}rpm", rt_ms, speed_number, speed_multiplier, time_str, - dv.soc_percent, dv.draw_watts, dv.charge_watts, dv.net_watts, dv.temp_c, dv.fan_level, - dv.fan_percent, dv.fan_rpm + dv.soc_percent, dv.draw_watts, dv.charge_watts, dv.net_watts, dv.temp_c, dv.fan_rpm ); } fn render_static(&mut self, sv: &StaticValues) { @@ -292,8 +291,6 @@ pub async fn integration_logic(core: &mut ControllerCore) -> Result margin { @@ -419,6 +415,10 @@ In `integration_logic.rs`, just above where we call `core.sysobs.update()`, add core.chg.last_psu_change_ms = now_ms; } + let fan_rpm = { + let ctrl = core.fan.controller().lock().await; + ctrl.fan.current_rpm() + }; ``` We'll have to bring in these imports in order to get to our charger policy functions, and the format helpers from our display models: ```rust @@ -442,54 +442,61 @@ Now we should see some policy behavior in action. If we run the program with `c This behavior is roughly equivalent to what we saw in our earlier integration attempts, but now we have a more structured and modular approach to handling the display rendering and the integration logic. We'll cap this off next by adding the thermal considerations. -### Thermal Policy +### Thermal Simulation -We also created some thermal policy functions in `thermal_governor.rs` earlier. We can use these to manage the fan speed based on the battery temperature. Let's grab what we need for imports: +We also created some thermal physics simulation functions in `thermal_model.rs` earlier. +Let's grab what we need for imports: ```rust use crate::model::thermal_model::step_temperature; -use crate::policy::thermal_governor::process_sample; -use mock_thermal::mock_fan_controller::level_to_pwm; ``` and add this code above the `// --- PSU attach/detach decision` comment in `integration_logic()`: ```rust // --- thermal model + governor + + // Charge power in Watts (only when attached and current is into the battery) + let chg_w: f32 = if core.chg.was_attached { + (act_chg_ma.max(0) as f32) * (mv as f32) / 1_000_000.0 + } else { + 0.0 + }; + // --- fan telemetry (and for thermal model) --- + let (rpm, min_rpm, max_rpm) = { + let mut fc = core.fan.controller().lock().await; // short lock + (fc.rpm().await.unwrap_or(0), fc.min_rpm(), fc.max_rpm()) + }; + + let sensor_temp = { + let ctrl = core.sensor.controller().lock().await; + ctrl.current_temp() + }; + + // --- thermal model --- let new_temp = step_temperature( - core.sensor.sensor.get_temperature(), + sensor_temp, load_ma, - core.therm.fan_level, + rpm, + min_rpm, + max_rpm, &core.cfg.sim.thermal, - dt_s + dt_s, + chg_w, ); + let c100 = (new_temp * 100.0).round().clamp(0.0, 65535.0) as u16; let _ = core.try_send(BusEvent::Thermal(ThermalEvent::TempSampleC100(c100))); - let hi_on = core.cfg.policy.thermal.temp_high_on_c; - let lo_on = core.cfg.policy.thermal.temp_low_on_c; - let td = process_sample( - new_temp, - core.therm.hi_latched, core.therm.lo_latched, - hi_on, lo_on, - core.cfg.policy.thermal.fan_hyst_c, - core.therm.last_fan_change_ms, core.cfg.policy.thermal.fan_min_dwell_ms, - now_ms, - ); - core.therm.hi_latched = td.hi_latched; - core.therm.lo_latched = td.lo_latched; - if !matches!(td.threshold_event, ThresholdEvent::None) { - core.send(BusEvent::Thermal(ThermalEvent::Threshold(td.threshold_event))).await; - } - if let Some(req) = td.cooling_request { - core.send(BusEvent::Thermal(ThermalEvent::CoolingRequest(req))).await; - if td.did_step { core.therm.last_fan_change_ms = now_ms; } - } + let fan_rpm = { + let ctrl = core.fan.controller().lock().await; + ctrl.fan.current_rpm() + }; ``` +This computes the temperature from the battery and sends it as a sensor event. + and then we can set the following `DisplayValues` fields for temperature and fan status: ```rust temp_c: new_temp, - fan_level: core.therm.fan_level as u8, - fan_percent: level_to_pwm(core.therm.fan_level, core.cfg.sim.thermal.max_fan_level), - fan_rpm: core.fan.fan.current_rpm(), + fan_rpm, ``` We now have all the components integrated and reporting. But nothing too exciting is happening because we only have a consistent load on the system that we've established at the start. diff --git a/guide_book/src/how/ec/integration/16-in_place_render.md b/guide_book/src/how/ec/integration/16-in_place_render.md index d904c89..10ba63b 100644 --- a/guide_book/src/how/ec/integration/16-in_place_render.md +++ b/guide_book/src/how/ec/integration/16-in_place_render.md @@ -2,6 +2,8 @@ At the start of this integration example series, we discussed how this application would serve as output both for logging changes, as an interactive simulator display, and as an integration test. We have so far implemented the logging display mode, which provides a useful perspective on system state changes over time. But we also want to implement the in-place rendering mode, which will provide a more interactive experience. +We'll walk through this here -- but this is not a part of component design per-se, and certainly not something that would be part of an embedded system -- this is just "eye-candy" that makes the use of the interactive simulation a little more comfortable. The code presented here is drop-in, so you don't need to worry about understanding ANSI control codes and other minutia of this part of the application. + As you might guess, the key to implementing the in-place rendering mode lies in completing the implementation of the `display_render/in_place_render.rs` file. Like its already-completed counterpart, `log_render.rs`, this file implements the `DisplayRenderer` trait. The key difference is that instead of printing out log lines, it will use terminal control codes to update the display in place. ### ANSI Escape Codes @@ -232,7 +234,7 @@ impl RendererBackend for InPlaceBackend { print!("Temp {:5.1} °C ", dv.temp_c); print!("{}{}Fan: ", fg(temp_zone.fg), bg(BG_PANEL)); print!("{}{}", bg(BG_PANEL), fg(FG_DEFAULT)); - println!(" L{} ({}%) -- {} rpm", dv.fan_level, dv.fan_percent, dv.fan_rpm); + println!(" {} rpm", dv.fan_rpm); // line + footer line_start(ROW_LINE2, COL_LEFT); diff --git a/guide_book/src/how/ec/integration/19-meaningful_tests.md b/guide_book/src/how/ec/integration/19-meaningful_tests.md index 215d67c..a910914 100644 --- a/guide_book/src/how/ec/integration/19-meaningful_tests.md +++ b/guide_book/src/how/ec/integration/19-meaningful_tests.md @@ -15,11 +15,14 @@ If we increase the load during any of this, the battery discharges faster, and t As we've written it, the test context does not have the ability to change the simulated time multiplier the way the interactive context allows, so all simulation time for the test runs at the pre-configured level 3 (25X). ### Running faster -Since this is a test, we don't need to dally. Let's make a change so that the default level for the integration-test mode is level 5 (100X). In `controller_core.rs` add the following lines below the temperature threshold settings within the set-aside mutex lock block: +Since this is a test, we don't need to dally. Let's make a change so that the default level for the integration-test mode is level 5 (100X). In `controller_core.rs` add the following lines at the top of the `controller_core_task` so that this executes once at the start before entering the event loop: ```rust - - #[cfg(feature = "integration-test")] + #[cfg(feature = "integration-test")] + { + let core = core_mutex.lock().await; core.sysobs.set_speed_number(5).await; + } + ``` ## Checking static, then stepwise events @@ -208,15 +211,16 @@ Replace the current `on_static` method with this one: ``` and we'll check some more of the starting values. Change the member function `check_starting_values()` to this version: ```rust - fn check_starting_values(&mut self, soc:f32, draw_watts:f32, charge_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + fn check_starting_values(&mut self, soc:f32, draw_watts:f32, charge_watts:f32, temp_c:f32, fan_rpm:u16) -> TestStep { let reporter = &mut self.reporter; add_test!(reporter, "Check Starting Values", |obs| { expect_eq!(obs, soc, 100.0); expect_eq!(obs, draw_watts, 9.4); expect_eq!(obs, charge_watts, 0.0); expect_to_decimal!(obs, temp_c, 24.6, 1); - expect_eq!(obs, fan_level, 0); + expect_eq!(obs, fan_rpm, 0); }); + //TestStep::CheckChargerAttach TestStep::EndAndReport } ``` @@ -227,9 +231,9 @@ and change the match arm to call it like this: let charge_watts = dv.charge_watts; let temp_c = dv.temp_c; let soc = dv.soc_percent; - let fan_level = dv.fan_level; + let fan_rpm = dv.fan_rpm; - self.step = self.check_starting_values(soc, draw_watts, charge_watts, temp_c, fan_level); + self.step = self.check_starting_values(soc, draw_watts, charge_watts, temp_c, fan_rpm); }, ``` Now we can be reasonably confident that we are starting out as expected before continuing. diff --git a/guide_book/src/how/ec/integration/2-move_events.md b/guide_book/src/how/ec/integration/2-move_events.md index 9be6bce..ad6e499 100644 --- a/guide_book/src/how/ec/integration/2-move_events.md +++ b/guide_book/src/how/ec/integration/2-move_events.md @@ -15,10 +15,6 @@ Add the following files within the `src/config` folder: `config/policy_config.rs`: ```rust - -use mock_thermal::mock_fan_controller::FanPolicy; - - #[derive(Clone)] /// Parameters for the charger *policy* (attach/detach + current/voltage requests). /// - Attach/Detach uses SOC hysteresis + idle gating (time since last heavy load). @@ -96,37 +92,60 @@ impl Default for ChargerPolicyCfg { } #[derive(Clone)] -/// Parameters for the *policy* (how we choose a fan level based on temperature). pub struct ThermalPolicyCfg { - /// Lower temperature (°C) where cooling begins (or where we allow stepping *down*). - /// Often the bottom of your control band. Keep < temp_high_on_c. - pub temp_low_on_c: f32, - - /// Upper temperature (°C) where stronger cooling is demanded (or step *up*). - /// Often the top of your control band. Keep > temp_low_on_c. - pub temp_high_on_c: f32, - - /// Fan hysteresis in °C applied around thresholds to prevent chatter. - /// Example: step up when T > (threshold + fan_hyst_c); step down when T < (threshold - fan_hyst_c). - pub fan_hyst_c: f32, - - /// Minimum time (ms) the fan must remain at the current level before another change. - /// Anti-flap dwell; choose ≥ your control loop interval and long enough to feel stable. - pub fan_min_dwell_ms: u64, - - /// The mapping/strategy for levels (e.g., L0..L3) → duty cycle (%), plus any custom rules. - /// Typically defines the % per level and possibly per-level entry/exit thresholds. - pub fan_policy: FanPolicy, + // Your existing band/hysteresis semantics + pub temp_low_on_c: f32, // “fan on” point / WARN-LOW + pub temp_high_on_c: f32, // begin ramp / WARN-HIGH + pub fan_hyst_c: f32, // used for both sensor & fan hysteresis + + // ODP Sensor Profile (ts::sensor::Profile) + pub sensor_prochot_c: f32, + pub sensor_crt_c: f32, + pub sensor_fast_sampling_threshold_c: f32, + pub sensor_sample_period_ms: u64, + pub sensor_fast_sample_period_ms: u64, + pub sensor_hysteresis_c: f32, // usually = fan_hyst_c + + // ODP Fan Profile (ts::fan::Profile) + pub fan_on_temp_c: f32, // typically = temp_low_on_c + pub fan_ramp_temp_c: f32, // typically = temp_high_on_c + pub fan_max_temp_c: f32, // typically = prochot or a bit under CRT + pub fan_sample_period_ms: u64, + pub fan_update_period_ms: u64, + pub fan_auto_control: bool, // true for ODP-controlled ramp } impl Default for ThermalPolicyCfg { fn default() -> Self { - Self { - temp_low_on_c: 22.0, - temp_high_on_c: 30.0, - fan_hyst_c: 1.5, - fan_min_dwell_ms: 1000, - fan_policy: FanPolicy::default() + // Sensible defaults; tweak as you wish. + let temp_low_on_c = 27.5; + let temp_high_on_c = 30.0; + let fan_hyst_c = 0.6; + + let sensor_prochot_c = 50.0; + let sensor_crt_c = 80.0; + + Self { + // legacy “band” semantics + temp_low_on_c, + temp_high_on_c, + fan_hyst_c, + + // sensor + sensor_prochot_c, + sensor_crt_c, + sensor_fast_sampling_threshold_c: temp_high_on_c, + sensor_sample_period_ms: 250, + sensor_fast_sample_period_ms: 100, + sensor_hysteresis_c: fan_hyst_c, + + // fan + fan_on_temp_c: temp_low_on_c, + fan_ramp_temp_c: temp_high_on_c, + fan_max_temp_c: sensor_prochot_c, // fan full speed before PROCHOT + fan_sample_period_ms: 250, + fan_update_period_ms: 250, + fan_auto_control: true, } } } @@ -135,9 +154,11 @@ impl Default for ThermalPolicyCfg { /// Combined settings that affect policy pub struct PolicyConfig { pub charger: ChargerPolicyCfg, - pub thermal: ThermalPolicyCfg, + // pub thermal: ThermalPolicyCfg, } ``` +The policy configurations for the charger work in concert with the functions we will define in `charger_policy.rs` (below). +The policy configurations for thermal mirror the policy settings used by the ODP thermal services that we will register with and attach to. `config/sim_config.rs`: ```rust @@ -168,10 +189,6 @@ pub struct ThermalModelCfg { /// Example: P_w ≈ (load_ma * v_nominal_mv) / 1_000_000. Use an average system/battery voltage. pub v_nominal_mv: u16, - /// Maximum discrete fan level your policy supports (e.g., 3 → L0..L3). - /// Used for clamping and for mapping policy levels to a %. - pub max_fan_level: u8, - /// fractional heat contributions of charger/charging power /// Rough guide: 5–10% PSU loss /// °C per Watt of charge power @@ -183,7 +200,6 @@ pub struct ThermalModelCfg { pub k_batt_chg: f32, } - #[allow(unused)] #[derive(Clone)] /// settings applied to the simulator behavior itself @@ -216,14 +232,20 @@ impl Default for SimConfig { Self { time: TimeSimCfg { sim_multiplier: 25.0 }, thermal: ThermalModelCfg { - ambient_c: 23.0, tau_s: 4.0, k_load_w: 0.20, k_fan_pct: 0.015, - v_nominal_mv: 8300, max_fan_level: 10, + ambient_c: 23.0, + tau_s: 8.0, + k_load_w: 0.16, + k_fan_pct: 0.027, // how effective cooling is + v_nominal_mv: 8300, + k_psu_loss: 0.04, // % of chg power shows up as heat in the box + k_batt_chg: 0.03, // % of battery heat }, device_caps: DeviceCaps { max_current_ma: 4800, max_voltage_mv: 15000 }, } } } ``` +These simulation configs give us some flexibility in how we compute the effects of type/physics for our virtual device implementations. `config/ui_config.rs`: ```rust @@ -366,94 +388,14 @@ pub fn decide_attach( AttachDecision { attach: was_attached, do_change: false } } ``` - -`policy/thermal_governor.rs`: -```rust - -use ec_common::events::{CoolingRequest, ThresholdEvent}; -use crate::state::ThermalState; - -pub struct ThermDecision { - pub threshold_event: ThresholdEvent, - pub cooling_request: Option, - pub hi_latched: bool, - pub lo_latched: bool, - pub did_step: bool, -} - -pub fn process_sample( - temp_c: f32, - hi_latched: bool, lo_latched: bool, - hi_on: f32, lo_on: f32, - hyst: f32, - last_fan_change_ms: u64, - dwell_ms: u64, - now_ms: u64, - mut state: ThermalState -) -> ThermDecision { - let hi_off = hi_on - hyst; - let lo_off = lo_on + hyst; - - let mut hi = hi_latched; - let mut lo = lo_latched; - if !hi && temp_c >= hi_on { hi = true; } - if hi && temp_c <= hi_off { hi = false; } - if !lo && temp_c <= lo_on { lo = true; } - if lo && temp_c >= lo_off { lo = false; } - - // Compute a *non-overlapping* neutral zone around the midpoint - let mid = 0.5 * (hi_on + lo_on); - let center_hyst = 0.5 * hyst; // or a new cfg.center_hyst_c if you want it independent - - let dwell_ok = now_ms - last_fan_change_ms >= dwell_ms; - - let want_dir: i8 = - if hi { 1 } - else if lo { -1 } - else if temp_c > mid + center_hyst { 1 } - else if temp_c < mid - center_hyst { -1 } - else { 0 }; - - let dir_dwell_ms = dwell_ms / 2; // or a separate cfg.fan_dir_dwell_ms - let reversing = want_dir != 0 && want_dir == -state.last_dir; - let dir_ok = !reversing || (now_ms - state.last_dir_change_ms >= dir_dwell_ms); - - let mut threshold_event = ThresholdEvent::None; - let mut cooling_request = None; - let mut did_step = false; - - if dwell_ok && dir_ok { - if want_dir > 0 { cooling_request = Some(CoolingRequest::Increase); did_step = true; } - if want_dir < 0 { cooling_request = Some(CoolingRequest::Decrease); did_step = true; } - } - - if did_step { - state.last_fan_change_ms = now_ms; - if want_dir != 0 && want_dir != state.last_dir { - state.last_dir = want_dir; - state.last_dir_change_ms = now_ms; - } - } - - - // Edge-generated events (for logging/telemetry), but not the only time we step - if hi && !hi_latched { - threshold_event = ThresholdEvent::OverHigh; - } else if lo && !lo_latched { - threshold_event = ThresholdEvent::UnderLow; - } - - ThermDecision { threshold_event, cooling_request, hi_latched: hi, lo_latched: lo, did_step } -} -``` +The `charger_policy.rs` functions are controlled by the charger configurations in `policy_cfg.rs` and define the rules by which we attach and detach the charger. `policy/mod.rs`: ```rust //policy pub mod charger_policy; -pub mod thermal_governor; ``` -These policy implementations define functions that control the decision rules for the charger and thermal components. +We only need a charger policy defined here. Thermal policy is provided by the ODP services. `state/charger_state.rs`: ```rust @@ -479,34 +421,6 @@ impl Default for ChargerState { } ``` -`state/thermal_state.rs`: -```rust - -#[derive(Copy, Clone)] -pub struct ThermalState { - pub fan_level: u8, - pub hi_latched: bool, - pub lo_latched: bool, - pub last_fan_change_ms: u64, - pub last_dir: i8, - pub last_dir_change_ms: u64 -} - -impl Default for ThermalState { - fn default() -> Self { - Self { - fan_level: 0, - hi_latched: false, - lo_latched: false, - last_fan_change_ms: 0, - last_dir: 0, - last_dir_change_ms: 0 - } - } -} - -``` - `state/sim_state.rs`: ```rust use embassy_time::Instant; @@ -526,11 +440,9 @@ impl Default for SimState { ```rust // state pub mod charger_state; -pub mod thermal_state; pub mod sim_state; pub use charger_state::ChargerState; -pub use thermal_state::ThermalState; pub use sim_state::SimState; ``` These states are used to track the current condition of the simulation and its components in action over time. @@ -539,13 +451,16 @@ These states are used to track the current condition of the simulation and its c ```rust use crate::config::sim_config::ThermalModelCfg; +// thermal_model.rs pub fn step_temperature( t: f32, load_ma: i32, - fan_level: u8, + fan_rpm: u16, + fan_min_rpm: u16, + fan_max_rpm: u16, cfg: &ThermalModelCfg, dt_s: f32, - chg_w: f32, // NEW: charge power in Watts (0 if not charging) + chg_w: f32, // charge power in Watts (0 if not charging) ) -> f32 { let load_w = (load_ma.max(0) as f32) * (cfg.v_nominal_mv as f32) / 1_000_000.0; @@ -553,7 +468,15 @@ pub fn step_temperature( let psu_heat_w = cfg.k_psu_loss * chg_w; // DC-DC inefficiency + board losses let batt_heat_w = cfg.k_batt_chg * chg_w; // battery internal resistance during charge - let fan_pct = 100.0 * (fan_level as f32) / (cfg.max_fan_level as f32).max(1.0); + // Normalize RPM → 0..1 → 0..100% (clamped) + let fan_frac = if fan_max_rpm <= fan_min_rpm { + 0.0 + } else { + ((fan_rpm.saturating_sub(fan_min_rpm)) as f32 + / (fan_max_rpm - fan_min_rpm) as f32) + .clamp(0.0, 1.0) + }; + let fan_pct = 100.0 * fan_frac; // Combined drive: ambient + load heat + charger/battery heat - fan cooling let drive = cfg.ambient_c @@ -648,19 +571,6 @@ pub enum ThermalEvent { ``` Now all of our event messaging can be referred to from the single enumeration source `BusEvent`, and our handlers can dispatch accordingly. - -Edit `thermal_project/mock_thermal/src/mock_sensor_controller.rs` to remove the definition of `ThresholdEvent` there, and add the following import: - -```rust -use ec_common::events::ThresholdEvent; -``` - -Edit `thermal_project/mock_thermal/src/mock_fan_controller.rs` to remove the definitions of `CoolingRequest`, `CoolingResult`, and `SpinUp`, and add the following imports: - -```rust -use ec_common::events::{CoolingRequest, CoolingResult, SpinUp}; -``` - We also need to add this to the `lib.rs` file of `ec_common` to make these types available to the rest of the crate: ```rust @@ -670,6 +580,4 @@ pub mod espi_service; pub mod fuel_signal_ready; pub mod test_helper; pub mod events; -``` - -Try a `cargo build` (or a `cargo check`) in `thermal_project` to ensure that everything is still compiling correctly. If you have not made any other changes there, it should compile without errors. +``` \ No newline at end of file diff --git a/guide_book/src/how/ec/integration/20-charger_attachment.md b/guide_book/src/how/ec/integration/20-charger_attachment.md index 1d00b3d..822d3c8 100644 --- a/guide_book/src/how/ec/integration/20-charger_attachment.md +++ b/guide_book/src/how/ec/integration/20-charger_attachment.md @@ -4,7 +4,7 @@ Let's continue on with the next step we've outlined in our `TestStep` series: `T To do this, create a new member function for this: ```rust - fn check_charger_attach(&mut self, mins_passed: f32, soc:f32, _draw_watts:f32, charge_watts:f32) -> TestStep{ + fn check_charger_attach(&mut self, mins_passed: f32, soc:f32, charge_watts:f32) -> TestStep { let reporter = &mut self.reporter; // Fail if we don't see our starting conditions within a reasonable time if mins_passed > 30.0 { // should occur before 30 minutes simulation time @@ -19,7 +19,9 @@ To do this, create a new member function for this: add_test!(reporter, "Check Charger Attachment", |obs| { expect!(obs, soc <= 90.0, "Attach expected <= 90% SOC"); }); - TestStep::EndAndReport // go to next step + // TestStep::RaiseLoadAndCheckTemp // go to next step + TestStep::EndAndReport + } ``` This is a little different because it first checks for qualifying (or disqualifying error) conditions before it begins the actual test closure. @@ -27,18 +29,16 @@ First, it checks to see if we've timed out -- using simulation time, and assumin We then check to see if the charger is attached, which is evidenced by `charge_watts > 0.0` until this is true, we return `TestStep::CheckChargerAttach` so that we continue to be called each frame until then. Once these conditional checks are done, we can test what it means to be in attached state and proceed to the next step, which in this case is `EndAndReport` until we add another test. -On that note, edit the return of `check_starting_values()` to now be `TestStep::CheckChargerAttach`. +On that note, edit the return of `check_starting_values()` to now be `TestStep::CheckChargerAttach` so that it chains to this one. Now, in the match arms for this, add this caller: ```rust TestStep::CheckChargerAttach => { let mins_passed = dv.sim_time_ms / 60_000.0; let soc = dv.soc_percent; - let draw_watts = dv.draw_watts; let charge_watts = dv.charge_watts; - self.step = self.check_charger_attach(mins_passed, soc, draw_watts, charge_watts); - + self.step = self.check_charger_attach(mins_passed, soc, charge_watts); }, ``` diff --git a/guide_book/src/how/ec/integration/21-affecting_change.md b/guide_book/src/how/ec/integration/21-affecting_change.md index 49ed631..4b11e75 100644 --- a/guide_book/src/how/ec/integration/21-affecting_change.md +++ b/guide_book/src/how/ec/integration/21-affecting_change.md @@ -2,7 +2,7 @@ For our next test, we want to raise the system load and then see how that affects temperature (it should rise). -We don't currently have a way to tell the simulation to raise the load. But in interactive mode we can, and we did that by sending `InteractionEvent` messages. Let's do that here. We'll need to pass in the `InteractionChannelWrapper` we need for sending these messages into the `interaction_test()` function. +We don't currently have a way to tell the simulation to raise the load. But in interactive mode we can, and we did that by sending `InteractionEvent` messages. Let's do that here. We'll need to pass in the `InteractionChannelWrapper` we need for sending these messages into the `integration_test()` function. Start by adding these imports: ```rust @@ -96,7 +96,8 @@ Now, fill out our `raise_load_and_check_temp` function to look like this: } // reset in case we want to use these again later self.mark_temp = None; - self.mark_time = None; + self.mark_time = None; + // TestStep::RaiseLoadAndCheckFan // next step TestStep::EndAndReport } ``` @@ -120,7 +121,7 @@ The next test we'll create is similar, but in this case, we'll raise the load (a Create the member function we'll need for this. it will look much like the previous one in many ways: ```rust - fn raise_load_and_check_fan(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + fn raise_load_and_check_fan(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_rpm:u16) -> TestStep { let reporter = &mut self.reporter; // record time we started this @@ -134,20 +135,20 @@ Create the member function we'll need for this. it will look much like the previ } let mt = *self.mark_time.get_or_insert(mins_passed); let time_elapsed = if mins_passed > mt { mins_passed - mt } else { 0.0 }; - if time_elapsed > 0.25 && fan_level == 0 { // this should happen relatively quickly (about 15 seconds of sim time) + if time_elapsed > 0.25 && fan_rpm == 0 { // this should happen relatively quickly add_test!(reporter, "Timed out waiting for fan", |obs| { obs.fail("Time expired"); }); - return TestStep::EndAndReport // end the test now on timeout error + return TestStep::EndAndReport // quit tests if we timeout } - if fan_level > 0 { - add_test!(reporter, "Fan turns on", |obs| { - obs.pass(); - }); + if fan_rpm > 0 { add_test!(reporter, "Temperature is warm", |obs| { expect!(obs, temp_c >= 28.0, "temp below fan on range"); }); + add_test!(reporter, "Fan turns on", |obs| { + obs.pass(); + }); } else { // keep going return TestStep::RaiseLoadAndCheckFan @@ -155,6 +156,7 @@ Create the member function we'll need for this. it will look much like the previ // reset in case we want to use these again later self.mark_temp = None; self.mark_time = None; + // TestStep::LowerLoadAndCheckCooling TestStep::EndAndReport } ``` @@ -164,11 +166,10 @@ add the calling case to the match arm: let mins_passed = dv.sim_time_ms / 60_000.0; let draw_watts = dv.draw_watts; let temp_c = dv.temp_c; - let fan_level = dv.fan_level; + let fan_rpm = dv.fan_rpm; - self.step = self.raise_load_and_check_fan(mins_passed, draw_watts, temp_c, fan_level); + self.step = self.raise_load_and_check_fan(mins_passed, draw_watts, temp_c, fan_rpm); }, - ``` Don't forget to update the next step return of the previous step so that it carries forward to this one. @@ -177,7 +178,7 @@ Great! Now, let's make sure the temperature goes back down with less demand on t Create the member function ```rust - fn lower_load_and_check_cooling(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_level:u8) -> TestStep { + fn lower_load_and_check_cooling(&mut self, mins_passed:f32, draw_watts:f32, temp_c:f32, fan_rpm:u16) -> TestStep { let reporter = &mut self.reporter; // record time and temp when we started this @@ -194,24 +195,22 @@ Create the member function // wait a bit let mark_time = *self.mark_time.get_or_insert(mins_passed); let diff = mins_passed - mark_time; - if diff > 60.0 { // wait for an hour for it to cool all the way + if diff < 60.0 { // wait for an hour for it to cool all the way return TestStep::LowerLoadAndCheckCooling } add_test!(reporter, "Cooled", |obs| { expect!(obs, draw_watts < 10.0, "Load < 10 W"); - println!("temp is {}", temp_c); expect!(obs, temp_c < 25.5, "Temp is < 25.5"); }); add_test!(reporter, "Fan turns off", |obs| { - expect_eq!(obs, fan_level, 0); + expect_eq!(obs, fan_rpm, 0); }); // reset in case we want to use these again later self.mark_temp = None; self.mark_time = None; TestStep::EndAndReport } - ``` and the caller in the match arm: ```rust @@ -219,9 +218,9 @@ and the caller in the match arm: let mins_passed = dv.sim_time_ms / 60_000.0; let draw_watts = dv.draw_watts; let temp_c = dv.temp_c; - let fan_level = dv.fan_level; + let fan_rpm = dv.fan_rpm; - self.step = self.lower_load_and_check_cooling(mins_passed, draw_watts, temp_c, fan_level); + self.step = self.lower_load_and_check_cooling(mins_passed, draw_watts, temp_c, fan_rpm); }, ``` And again, remember to update the return value for the next step of the `load_and_check_fan` method to be `TestStep::LowerLoadAndCheckCooling` so that it chains to this one properly. @@ -230,7 +229,7 @@ Your `cargo run --features integration-test` should now complete in about 40 sec ``` ================================================== Test Section Report - Duration: 38.2245347s + Duration: 35.4803276s [PASS] Static Values received [PASS] First Test Data Frame received diff --git a/guide_book/src/how/ec/integration/4-update_controller.md b/guide_book/src/how/ec/integration/4-update_controller.md index 56816a1..eaa8a60 100644 --- a/guide_book/src/how/ec/integration/4-update_controller.md +++ b/guide_book/src/how/ec/integration/4-update_controller.md @@ -11,39 +11,7 @@ This latter approach is a preferable pattern because it ensures that the same co We use the `get_internals()` method to return both the component and the `Device` instances instead of simply `inner_charger` because splitting the reference avoids inherent internal borrows on the same mutable 'self' reference. -Update `charger_project/mock_charger/src/mock_charger_controller.rs` so that the `MockChargerController` definition itself looks like this: - -```rust -pub struct MockChargerController { - pub charger: &'static mut MockCharger, - pub device: &'static mut Device -} - -impl MockChargerController -{ - pub fn new(device: &'static mut MockChargerDevice) -> Self { - let (charger, device) = device.get_internals(); - Self { charger, device } - } -} -``` -and then replace any references in the code of -```rust -self.device.inner_charger() -``` -to become -```rust -self.charger -``` -and lastly, change -```rust -let inner = controller.charger; -``` -to become -```rust -let inner = &controller.charger; -``` -to complete the revisions for `MockChargerController`. +We'll be updating many of our previous controllers, as well as `MockChargerDevice` to make things consistent and to ensure are using the right types and traits required for the ODP service registrations. >📌 Why does `get_internals()` work where inner_charger() fails? > @@ -58,10 +26,24 @@ to complete the revisions for `MockChargerController`. > This is why controllers like our `MockSensorController`, `MockFanController`, and now `MockChargerController` can be cleanly instantiated with `get_internals()`. The `MockBatteryController` happens not to need this because it never touches the `Device` half of `MockBatteryDevice` — it only needs the component itself. ### The other Controllers -In our `MockSensorController` and `MockFanController` definitions, we did not make our `Device` or component members accessible, so we will change those now to do that and make them public: +In our `MockSensorController` and `MockFanController` definitions, we did not make our `Device` or component members accessible, so we will change those now to do that and make them public. +It also turns out that we need a few changes to the implemented traits for these controllers that are necessary to make these eligible for registering for the ODP thermal services. Plus, we will add a couple of new helper accessor functions to simplify our usage later. -In `mock_sensor_controller.rs`: +Update `thermal_project/mock_thermal/src/mock_sensor_controller.rs` with this new version: ```rust + +use crate::mock_sensor::{MockSensor, MockSensorError}; +use crate::mock_sensor_device::MockSensorDevice; +use embedded_services::power::policy::device::Device; + +use embedded_sensors_hal_async::temperature::{ + DegreesCelsius, TemperatureSensor, TemperatureThresholdSet +}; +use ec_common::events::ThresholdEvent; +use embedded_sensors_hal_async::{sensor as sens, temperature as temp}; +use thermal_service as ts; + + pub struct MockSensorController { pub sensor: &'static mut MockSensor, pub device: &'static mut Device @@ -79,10 +61,251 @@ impl MockSensorController { } } + // Check if temperature has exceeded the high/low thresholds and + // issue an event if so. Protect against hysteresis. + const HYST: f32 = 0.5; + pub fn eval_thresholds(&mut self, t:f32, lo:f32, hi:f32, + hi_latched: &mut bool, lo_latched: &mut bool) -> ThresholdEvent { + + // trip rules: >= hi and <= lo (choose your exact policy) + if t >= hi && !*hi_latched { + *hi_latched = true; + *lo_latched = false; + return ThresholdEvent::OverHigh; + } + if t <= lo && !*lo_latched { + *lo_latched = true; + *hi_latched = false; + return ThresholdEvent::UnderLow; + } + // clear latches only after re-entering band with hysteresis + if t < hi - Self::HYST { *hi_latched = false; } + if t > lo + Self::HYST { *lo_latched = false; } + ThresholdEvent::None + } + + pub fn set_sim_temp(&mut self, t: f32) { self.sensor.set_temperature(t); } + pub fn current_temp(&self) -> f32 { self.sensor.get_temperature() } + +} + +impl sens::ErrorType for MockSensorController { + type Error = MockSensorError; +} + +impl temp::TemperatureSensor for MockSensorController { + async fn temperature(&mut self) -> Result { + self.sensor.temperature().await + } +} +impl temp::TemperatureThresholdSet for MockSensorController { + async fn set_temperature_threshold_low(&mut self, threshold: DegreesCelsius) -> Result<(), Self::Error> { + self.sensor.set_temperature_threshold_low(threshold).await + + } + + async fn set_temperature_threshold_high(&mut self, threshold: DegreesCelsius) -> Result<(), Self::Error> { + self.sensor.set_temperature_threshold_high(threshold).await + } +} + +impl ts::sensor::Controller for MockSensorController {} +impl ts::sensor::CustomRequestHandler for MockSensorController {} + + + +// -------------------- +#[cfg(test)] +use ec_common::test_helper::join_signals; +#[allow(unused_imports)] +use embassy_executor::Executor; +#[allow(unused_imports)] +use embassy_sync::signal::Signal; +#[allow(unused_imports)] +use static_cell::StaticCell; +#[allow(unused_imports)] +use embedded_services::power::policy::DeviceId; +#[allow(unused_imports)] +use ec_common::mutex::{Mutex, RawMutex}; + +// Tests that don't need async +#[test] +fn threshold_crossings_and_hysteresis() { + // Build a controller with a default sensor + static DEVICE: StaticCell = StaticCell::new(); + static CONTROLLER: StaticCell = StaticCell::new(); + let device = DEVICE.init(MockSensorDevice::new(DeviceId(1))); + let controller = CONTROLLER.init(MockSensorController::new(device)); + let mut hi_lat = false; + let mut lo_lat = false; + + // Program thresholds via helpers or direct fields for tests + let (lo, hi) = (45.0_f32, 50.0_f32); + + // Script: (t, expect) + use crate::mock_sensor_controller::ThresholdEvent::*; + let steps = [ + (49.9, None), + (50.1, OverHigh), + (49.8, None), // still latched above-hi, no duplicate + (49.3, None), // cross below hi - hyst clears latch + (44.9, UnderLow), + (45.3, None), // cross above low + hyst clears latch + ]; + + for (t, want) in steps { + let got = controller.eval_thresholds(t, lo, hi, &mut hi_lat, &mut lo_lat); + assert_eq!(got, want, "t={t}°C"); + } +} + +// Tests that need async tasks -- +#[test] +fn test_controller() { + static EXECUTOR: StaticCell = StaticCell::new(); + + static DEVICE: StaticCell = StaticCell::new(); + static CONTROLLER: StaticCell = StaticCell::new(); + + static TSV_DONE: StaticCell> = StaticCell::new(); + + let executor = EXECUTOR.init(Executor::new()); + + let tsv_done = TSV_DONE.init(Signal::new()); + + executor.run(|spawner| { + let device = DEVICE.init(MockSensorDevice::new(DeviceId(1))); + let controller = CONTROLLER.init(MockSensorController::new(device)); + + let _ = spawner.spawn(test_setting_values(controller, tsv_done)); + + join_signals(&spawner, [ + // vis_done, + tsv_done + ]); + }); +} + +// check initial state, then +// set temperature, thresholds low and high, check sync with the underlying state +#[embassy_executor::task] +async fn test_setting_values( + controller: &'static mut MockSensorController, + done: &'static Signal +) +{ + // verify initial state + assert_eq!(0.0, controller.sensor.get_temperature()); + assert_eq!(f32::NEG_INFINITY, controller.sensor.get_threshold_low()); + assert_eq!(f32::INFINITY, controller.sensor.get_threshold_high()); + + let temp = 12.34; + let low = -56.78; + let hi = 67.89; + controller.sensor.set_temperature(temp); + let _ = controller.set_temperature_threshold_low(low).await; + let _ = controller.set_temperature_threshold_high(hi).await; + let rtemp = controller.temperature().await.unwrap(); + assert_eq!(rtemp, temp); + assert_eq!(controller.sensor.get_threshold_low(), low); + assert_eq!(controller.sensor.get_threshold_high(), hi); + + done.signal(()); +} ``` -In `mock_fan_controller.rs`: +Likewise, update `thermal_project/mock_thermal/src/mock_fan_controller.rs` to this: ```rust + +use core::future::Future; +use crate::mock_fan::{MockFan, MockFanError}; +use crate::mock_fan_device::MockFanDevice; +use embedded_services::power::policy::device::Device; + +use embedded_fans_async as fans; +use embedded_fans_async::{Fan, RpmSense}; +use thermal_service as ts; + +use ec_common::events::{CoolingRequest, CoolingResult, SpinUp}; + +/// Policy Configuration values for behavior logic +#[derive(Debug, Clone, Copy)] +pub struct FanPolicy { + /// Max discrete cooling level (e.g., 10 means levels 0..=10). + pub max_level: u8, + /// Step per Increase/Decrease (in “levels”). + pub step: u8, + /// If going 0 -> >0, kick the fan to at least this RPM briefly. + pub min_start_rpm: u16, + /// The level you jump to on the first Increase from 0. + pub start_boost_level: u8, + /// How long to hold the spin-up RPM before dropping to level RPM. + pub spinup_hold_ms: u32, +} + +impl Default for FanPolicy { + fn default() -> Self { + Self { + max_level: 10, + step: 2, + min_start_rpm: 1200, + start_boost_level: 3, + spinup_hold_ms: 300, + } + } +} + +/// Linear mapping helper: level (0..=max) → PWM % (0..=100). +#[inline] +pub fn level_to_pwm(level: u8, max_level: u8) -> u8 { + if max_level == 0 { return 0; } + ((level as u16 * 100) / (max_level as u16)) as u8 +} + +/// Percentage mapping helper: pick a percentage of the range +#[inline] +pub fn percent_to_rpm_range(min: u16, max: u16, percent: u8) -> u16 { + let p = percent.min(100) as u32; + let span = (max - min) as u32; + min + (span * p / 100) as u16 +} +/// Percentage mapping helper: pick a percentage of the max +#[inline] +pub fn percent_to_rpm_max(max: u16, percent: u8) -> u16 { + (max as u32 * percent.min(100) as u32 / 100) as u16 +} +/// Core policy: pure, no I/O. Call this from your controller when you receive a cooling request. +/// Later, if `spinup` is Some, briefly force RPM, then set RPM to `target_rpm_percent`. +pub fn apply_cooling_request(cur_level: u8, req: CoolingRequest, policy: &FanPolicy) -> CoolingResult { + // Sanitize policy + let max = policy.max_level.max(1); + let step = policy.step.max(1); + let boost = policy.start_boost_level.clamp(1, max); + + let mut new_level = cur_level.min(max); + let mut spinup = None; + + match req { + CoolingRequest::Increase => { + if new_level == 0 { + new_level = boost; + spinup = Some(SpinUp { rpm: policy.min_start_rpm, hold_ms: policy.spinup_hold_ms }); + } else { + new_level = new_level.saturating_add(step).min(max); + } + } + CoolingRequest::Decrease => { + new_level = new_level.saturating_sub(step); + } + } + + CoolingResult { + new_level, + target_rpm_percent: level_to_pwm(new_level, max), + spinup, + } +} + pub struct MockFanController { pub fan: &'static mut MockFan, pub device: &'static mut Device @@ -102,16 +325,222 @@ impl MockFanController { device } } + + /// Execute behavior policy for a cooling request + pub async fn handle_request( + &mut self, + cur_level: u8, + req: CoolingRequest, + policy: &FanPolicy, + ) -> Result<(CoolingResult, u16), MockFanError> { + let res = apply_cooling_request(cur_level, req, policy); + if let Some(sp) = res.spinup { + // 1) force RPM to kick the rotor + let _ = self.set_speed_rpm(sp.rpm).await?; + // 2) hold for `sp.hold_ms` with embassy_time to allow spin up first + embassy_time::Timer::after(embassy_time::Duration::from_millis(sp.hold_ms as u64)).await; + } + let pwm = level_to_pwm(res.new_level, policy.max_level); + let rpm = self.set_speed_percent(pwm).await?; + Ok((res, rpm)) + } +} + +impl fans::ErrorType for MockFanController { + type Error = MockFanError; +} + +impl fans::Fan for MockFanController { + fn min_rpm(&self) -> u16 { + self.fan.min_rpm() + } + + + fn max_rpm(&self) -> u16 { + self.fan.max_rpm() + } + + fn min_start_rpm(&self) -> u16 { + self.fan.min_start_rpm() + } + + fn set_speed_rpm(&mut self, rpm: u16) -> impl Future> { + self.fan.set_speed_rpm(rpm) + } +} + +impl fans::RpmSense for MockFanController { + fn rpm(&mut self) -> impl Future> { + self.fan.rpm() + } +} + +// Allow thermal service to drive us with default linear ramp +impl ts::fan::CustomRequestHandler for MockFanController {} +impl ts::fan::RampResponseHandler for MockFanController {} +impl ts::fan::Controller for MockFanController {} + +// -------------------- +#[cfg(test)] +use ec_common::test_helper::join_signals; +#[allow(unused_imports)] +use embassy_executor::Executor; +#[allow(unused_imports)] +use embassy_sync::signal::Signal; +#[allow(unused_imports)] +use static_cell::StaticCell; +#[allow(unused_imports)] +use embedded_services::power::policy::DeviceId; +#[allow(unused_imports)] +use ec_common::mutex::{Mutex, RawMutex}; + +// Tests that don't need async +#[test] +fn increase_from_zero_triggers_spinup_then_levels() { + let p = FanPolicy { min_start_rpm: 1000, spinup_hold_ms: 250, ..FanPolicy::default() }; + let r1 = apply_cooling_request(0, CoolingRequest::Increase, &p); + assert_eq!(r1.new_level, 3); + assert_eq!(r1.target_rpm_percent, 30); + assert_eq!(r1.spinup, Some(SpinUp { rpm: 1000, hold_ms: 250 })); + + // Next increase: no spinup, just step + let r2 = apply_cooling_request(r1.new_level, CoolingRequest::Increase, &p); + assert_eq!(r2.new_level, 5); + assert_eq!(r2.spinup, None); +} + +#[test] +fn saturates_at_bounds_and_is_idempotent_at_extremes() { + let p = FanPolicy::default(); + + // Clamp at max + let r = apply_cooling_request(10, CoolingRequest::Increase, &p); + assert_eq!(r.new_level, 10); + assert_eq!(r.spinup, None); + + // Clamp at 0 + let r = apply_cooling_request(1, CoolingRequest::Decrease, &p); + assert_eq!(r.new_level, 0); + let r = apply_cooling_request(0, CoolingRequest::Decrease, &p); + assert_eq!(r.new_level, 0); +} + +#[test] +fn mapping_to_rpm_is_linear_and_total() { + assert_eq!(level_to_pwm(0, 10), 0); + assert_eq!(level_to_pwm(5, 10), 50); + assert_eq!(level_to_pwm(10, 10), 100); +} + +// Tests that need async tasks -- +#[test] +fn test_setting_values() { + static EXECUTOR: StaticCell = StaticCell::new(); + static DEVICE: StaticCell = StaticCell::new(); + static CONTROLLER: StaticCell = StaticCell::new(); + static DONE: StaticCell> = StaticCell::new(); + + let executor = EXECUTOR.init(Executor::new()); + let done = DONE.init(Signal::new()); + + executor.run(|spawner| { + let device = DEVICE.init(MockFanDevice::new(DeviceId(1))); + let controller = CONTROLLER.init(MockFanController::new(device)); + + // run these tasks sequentially + let _ = spawner.spawn(setting_values_test_task(controller, done)); + join_signals(&spawner, [done]); + }); +} +#[test] +fn test_handle_request() { + static EXECUTOR: StaticCell = StaticCell::new(); + static DEVICE: StaticCell = StaticCell::new(); + static CONTROLLER: StaticCell = StaticCell::new(); + static DONE: StaticCell> = StaticCell::new(); + + let executor = EXECUTOR.init(Executor::new()); + let done = DONE.init(Signal::new()); + + executor.run(|spawner| { + let device = DEVICE.init(MockFanDevice::new(DeviceId(1))); + let controller = CONTROLLER.init(MockFanController::new(device)); + + // run these tasks sequentially + let _ = spawner.spawn(handle_request_test_task(controller, done)); + join_signals(&spawner, [done]); + }); +} + +// check initial state, then +// set temperature, thresholds low and high, check sync with the underlying state +#[embassy_executor::task] +async fn setting_values_test_task( + controller: &'static mut MockFanController, + done: &'static Signal +) +{ + use crate::virtual_fan::{FAN_RPM_MINIMUM, FAN_RPM_MAXIMUM, FAN_RPM_START}; + // verify initial state + let rpm = controller.rpm().await.unwrap(); + let min = controller.min_rpm(); + let max = controller.max_rpm(); + let min_start = controller.min_start_rpm(); + assert_eq!(rpm, 0); + assert_eq!(min, FAN_RPM_MINIMUM); + assert_eq!(max, FAN_RPM_MAXIMUM); + assert_eq!(min_start, FAN_RPM_START); + + // now set values and verify them + let _ = controller.set_speed_max().await; + let v = controller.rpm().await.unwrap(); + assert_eq!(v, FAN_RPM_MAXIMUM); + let _ = controller.set_speed_percent(50).await; + let v = controller.rpm().await.unwrap(); + assert_eq!(v, FAN_RPM_MAXIMUM / 2); + let _ = controller.set_speed_rpm(0).await; + let v = controller.rpm().await.unwrap(); + assert_eq!(v, 0); + + done.signal(()); +} + +#[embassy_executor::task] +async fn handle_request_test_task( + controller: &'static mut MockFanController, + done: &'static Signal +) { + let policy = FanPolicy { min_start_rpm: 1000, spinup_hold_ms: 0, ..Default::default() }; + + // Start from 0, request Increase -> expect spinup and final RPM for boost level + let (res1, rpm1) = controller.handle_request(0, CoolingRequest::Increase, &policy).await.unwrap(); + assert!(res1.spinup.is_some(), "should spin up from 0"); + assert_eq!(res1.new_level, policy.start_boost_level); + + // Final RPM should match the percent mapping for the new level + let expect1 = percent_to_rpm_max(controller.max_rpm(), level_to_pwm(res1.new_level, policy.max_level)); + assert_eq!(rpm1, expect1); + + // Next increase -> no spinup; just step up by `step` + let (res2, rpm2) = controller.handle_request(res1.new_level, CoolingRequest::Increase, &policy).await.unwrap(); + assert!(res2.spinup.is_none()); + assert_eq!(res2.new_level, (res1.new_level + policy.step).min(policy.max_level)); + + let expect2 = percent_to_rpm_max(controller.max_rpm(), level_to_pwm(res2.new_level, policy.max_level)); + assert_eq!(rpm2, expect2); + + done.signal(()); +} ``` We mentioned that we originally implemented `MockBatteryController` as being constructed without a `Device` element, but we _will_ need to access this device context later, so we should expose that as public member in the same way. While we are at it, we should also eliminate the generic design of the structure definition, since it is only adding unnecessary complexity and inconsistency. -Update `battery_project/mock_battery/mock_battery_component.rs` so that it now looks like this (consistent with the others): +Update `battery_project/mock_battery/mock_battery_controller.rs` so that it now looks like this (consistent with the others): ```rust use battery_service::controller::{Controller, ControllerEvent}; use battery_service::device::{DynamicBatteryMsgs, StaticBatteryMsgs}; -use embassy_time::{Duration, Timer}; +use embassy_time::{Duration, Timer}; use crate::mock_battery::{MockBattery, MockBatteryError}; use crate::mock_battery_device::MockBatteryDevice; use embedded_services::power::policy::device::Device; @@ -274,16 +703,16 @@ impl SmartBattery for MockBatteryController self.battery.serial_number().await } - async fn manufacturer_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { - self.battery.manufacturer_name(v).await + async fn manufacturer_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.manufacturer_name(buf).await } - async fn device_name(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { - self.battery.device_name(v).await + async fn device_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_name(buf).await } - async fn device_chemistry(&mut self, v: &mut [u8]) -> Result<(), Self::Error> { - self.battery.device_chemistry(v).await + async fn device_chemistry(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { + self.battery.device_chemistry(buf).await } } @@ -300,7 +729,7 @@ impl Controller for MockBatteryController let mut device = [0u8; 21]; let mut chem = [0u8; 5]; - println!("MockBatteryController: Fetching static data"); + // println!("MockBatteryController: Fetching static data"); self.battery.manufacturer_name(&mut name).await?; self.battery.device_name(&mut device).await?; @@ -335,7 +764,7 @@ impl Controller for MockBatteryController async fn get_dynamic_data(&mut self) -> Result { - println!("MockBatteryController: Fetching dynamic data"); + // println!("MockBatteryController: Fetching dynamic data"); // Pull values from SmartBattery trait let full_capacity = match self.battery.full_charge_capacity().await? { @@ -424,22 +853,6 @@ As previously mentioned, note that these changes break the constructor calling i You would need to change any references to MockBatteryController in any of the existing code that uses the former version to be simply `MockBatteryController` and will need to update any calls to the constructor to pass the `MockBatteryDevice` instance instead of `MockBattery`. There are likely other ramifications with regard to multiple borrows that still remain in the previous code that you will have to choose how to mitigate as well. -### As long as we're updating controllers... -An oversight in the implementation of some of the `SmartBattery` traits of `MockBatteryController` fail to pass the buffer parameter down into the underlying implementation. Although this won't materially affect the build here, it should be remedied. Replace these methods in `battery_project/mock_battery/src/mock_battery_controller.rs` with the versions below: - -```rust - async fn manufacturer_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { - self.battery.manufacturer_name(buf).await - } - - async fn device_name(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { - self.battery.device_name(buf).await - } - - async fn device_chemistry(&mut self, buf: &mut [u8]) -> Result<(), Self::Error> { - self.battery.device_chemistry(buf).await - } -``` diff --git a/guide_book/src/how/ec/integration/5-structural_steps.md b/guide_book/src/how/ec/integration/5-structural_steps.md index 0361da8..34b7e4b 100644 --- a/guide_book/src/how/ec/integration/5-structural_steps.md +++ b/guide_book/src/how/ec/integration/5-structural_steps.md @@ -154,6 +154,7 @@ We will focus first on the simulation app aspects before considering our integra To implement our UI, we introduce a `SystemObserver`, an intermediary between the simulation and the UI, including handling the rendering. Our rendering will assume two forms: We'll support a conventional "Logging" output that simply prints lines in sequence to the console as the values occur, because this is useful for analysis and debugging of behavior over time. But we will also support ANSI terminal cursor coding to support an "in-place" display that presents more of a dashboard view with changing values. This makes evaluation of the overall behavior and "feel" of our simulation and its behavior a little more approachable. +Don't worry, we won't spend a lot of time on UI design or implementation techniques - just drop in code that will make for a easier to use interface. Our simulation will also be interactive, allowing us to simulate increasing and decreasing load on the system, as one might experience during use of a typical laptop computer. @@ -200,10 +201,6 @@ pub struct DisplayValues { pub soc_percent: f32, /// battery/sensor temperature (Celsius) pub temp_c: f32, - /// Fan Level (integer number 0-10) - pub fan_level: u8, - /// Fan level percentage - pub fan_percent: u8, /// Fan running RPM pub fan_rpm: u16, @@ -228,8 +225,6 @@ impl DisplayValues { sim_time_ms: 0.0, soc_percent: 0.0, temp_c: 0.0, - fan_level: 0, - fan_percent: 0, fan_rpm: 0, load_ma: 0, diff --git a/guide_book/src/how/ec/integration/7-setup_and_tap.md b/guide_book/src/how/ec/integration/7-setup_and_tap.md index feb69c0..b414d12 100644 --- a/guide_book/src/how/ec/integration/7-setup_and_tap.md +++ b/guide_book/src/how/ec/integration/7-setup_and_tap.md @@ -40,8 +40,8 @@ flowchart LR CC[ControllerCore] B[MockBatteryController] C[MockChargerController] - S[MockSensorController] - F[MockFanController] + S[Thermal Service Sensor] + F[Thermal Service Fan] CC --> B & C & S & F end @@ -84,6 +84,7 @@ use mock_battery::mock_battery_controller::MockBatteryController; use mock_charger::mock_charger_device::MockChargerDevice; use mock_charger::mock_charger_controller::MockChargerController; use embedded_services::power::policy::charger::Device as ChargerDevice; // disambiguate from other device types +use embedded_services::power::policy::policy::register_charger; use embedded_services::power::policy::charger::ChargerId; use mock_thermal::mock_sensor_device::MockSensorDevice; @@ -95,6 +96,12 @@ use battery_service::wrapper::Wrapper; use crate::battery_adapter::BatteryAdapter; +use thermal_service as ts; +use ts::sensor as tss; +use ts::fan as tsf; + +pub const INTERNAL_SAMPLE_BUF_LENGTH:usize = 16; // must be a power of 2 + // ---------- statics that must live for 'static tasks ---------- static BATTERY_WRAPPER: StaticCell> = StaticCell::new(); @@ -107,9 +114,71 @@ static CHARGER_SERVICE_DEVICE: OnceLock = OnceLock::new(); static SENSOR_DEVICE: StaticCell = StaticCell::new(); -static SENSOR_POLICY_DEVICE: StaticCell = StaticCell::new(); static FAN_DEVICE: StaticCell = StaticCell::new(); -static FAN_POLICY_DEVICE: StaticCell = StaticCell::new(); + +static TS_SENSOR: StaticCell> = StaticCell::new(); +static TS_FAN: StaticCell> = StaticCell::new(); + +// Generate Embassy tasks for concrete controller types. +ts::impl_sensor_task!( + thermal_sensor_task, + mock_thermal::mock_sensor_controller::MockSensorController, + INTERNAL_SAMPLE_BUF_LENGTH +); + +ts::impl_fan_task!( + thermal_fan_task, + mock_thermal::mock_fan_controller::MockFanController, + INTERNAL_SAMPLE_BUF_LENGTH +); + +use crate::config::policy_config::ThermalPolicyCfg; + + +pub fn make_sensor_profile(p: &ThermalPolicyCfg) -> tss::Profile { + tss::Profile { + // thresholds + warn_low_threshold: p.temp_low_on_c, + warn_high_threshold: p.temp_high_on_c, + prochot_threshold: p.sensor_prochot_c, + crt_threshold: p.sensor_crt_c, + + // debouncing + hysteresis: p.sensor_hysteresis_c, + + // sampling + sample_period: p.sensor_sample_period_ms, + fast_sample_period: p.sensor_fast_sample_period_ms, + fast_sampling_threshold: p.sensor_fast_sampling_threshold_c, + + // misc + offset: 0.0, + retry_attempts: 5, + + ..Default::default() + } +} + +pub fn make_fan_profile(p: &ThermalPolicyCfg, sensor_id: tss::DeviceId) -> tsf::Profile { + tsf::Profile { + sensor_id, + + // ramp shape + on_temp: p.fan_on_temp_c, + ramp_temp: p.fan_ramp_temp_c, + max_temp: p.fan_max_temp_c, + hysteresis: p.fan_hyst_c, + + // control + auto_control: p.fan_auto_control, + + // sampling/update cadences + sample_period: p.fan_sample_period_ms, + update_period: p.fan_update_period_ms, + + ..Default::default() + } +} /// Initialize registration of all the integration components #[embassy_executor::task] @@ -129,26 +198,59 @@ pub async fn setup_and_tap_task(spawner: Spawner, shared: &'static Shared) { let battery_controller = MockBatteryController::new(battery_dev); // Similar for others, although they are not moved into wrapper - let charger_dev = CHARGER_DEVICE.init(MockChargerDevice::new(DeviceId(CHARGER_DEV_NUM))); - let charger_policy_dev = CHARGER_POLICY_DEVICE.init(MockChargerDevice::new(DeviceId(CHARGER_DEV_NUM))); + let charger_dev = CHARGER_DEVICE.init(MockChargerDevice::new(ChargerId(CHARGER_DEV_NUM))); + let charger_policy_dev = CHARGER_POLICY_DEVICE.init(MockChargerDevice::new(ChargerId(CHARGER_DEV_NUM))); let charger_controller = MockChargerController::new(charger_dev); // Thermal (controllers own their devices) let sensor_dev = SENSOR_DEVICE.init(MockSensorDevice::new(DeviceId(SENSOR_DEV_NUM))); - let sensor_policy_dev = SENSOR_POLICY_DEVICE.init(MockSensorDevice::new(DeviceId(SENSOR_DEV_NUM))); let fan_dev = FAN_DEVICE.init(MockFanDevice::new(DeviceId(FAN_DEV_NUM))); - let fan_policy_dev = FAN_POLICY_DEVICE.init(MockFanDevice::new(DeviceId(FAN_DEV_NUM))); + + // Build profiles from config + let thermal_cfg = ThermalPolicyCfg::default(); + let sensor_profile = make_sensor_profile(&thermal_cfg); + let fan_profile = make_fan_profile(&thermal_cfg, + tss::DeviceId(SENSOR_DEV_NUM as u8)); + // create controllers let sensor_controller = MockSensorController::new(sensor_dev); let fan_controller = MockFanController::new(fan_dev); + // build ODP registration-ready wrappers for these + let sensor = TS_SENSOR.init(tss::Sensor::new( + tss::DeviceId(SENSOR_DEV_NUM as u8), + sensor_controller, + sensor_profile, + )); + let fan = TS_FAN.init(tsf::Fan::new( + tsf::DeviceId(FAN_DEV_NUM as u8), + fan_controller, + fan_profile, + )); + + println!("🌡️ Initializing thermal service"); + thermal_service::init().await.unwrap(); + + // Register with the thermal service + println!("🧩 Registering sensor device to thermal service..."); + ts::register_sensor(sensor.device()).await.unwrap(); + println!("🧩 Registering fan device to thermal service..."); + ts::register_fan(fan.device()).await.unwrap(); + + + // Spawn the ODP thermal tasks (these tasks created by ts::impl_ macros at module scope above) + spawner.must_spawn(thermal_sensor_task(sensor)); + spawner.must_spawn(thermal_fan_task(fan)); + // To support MPTF/host messages: + spawner.must_spawn(ts::mptf::handle_requests()); + let charger_service_device: &'static ChargerDevice = CHARGER_SERVICE_DEVICE.get_or_init(|| ChargerDevice::new(ChargerId(CHARGER_DEV_NUM))); // Then use these to create our ControllerTap handler, which isolates ownership of all but the battery, which is // owned by the Wrapper. We can access the other "real" controllers upon battery message receipts by the Tap. // We must still stick to message passing to communicate between components to preserve modularity. let controller_core = ControllerCore::new( - battery_controller, charger_controller, sensor_controller, fan_controller, + battery_controller, charger_controller, sensor, fan, charger_service_device, shared.battery_channel,shared.charger_channel,shared.thermal_channel,shared.interaction_channel, shared.observer, @@ -172,13 +274,7 @@ pub async fn setup_and_tap_task(spawner: Spawner, shared: &'static Shared) { register_device(battery_policy_dev).await.unwrap(); println!("🧩 Registering charger device..."); - register_device(charger_policy_dev).await.unwrap(); - - println!("🧩 Registering sensor device..."); - register_device(sensor_policy_dev).await.unwrap(); - - println!("🧩 Registering fan device..."); - register_device(fan_policy_dev).await.unwrap(); + register_charger(charger_policy_dev).await.unwrap(); // ----------------- Fuel gauge / ready ----------------- println!("🔌 Initializing battery fuel gauge service..."); @@ -186,6 +282,13 @@ pub async fn setup_and_tap_task(spawner: Spawner, shared: &'static Shared) { spawner.spawn(battery_start_task()).unwrap(); + // insure launched tasks have started running before we execute request + embassy_futures::yield_now().await; + + // Turn on auto control for fan + println!("💡Turning on Fan EnableAutoControl..."); + let _ = fan.device().execute_request(thermal_service::fan::Request::EnableAutoControl).await; + // signal that the battery fuel service is ready shared.battery_ready.signal(); @@ -193,13 +296,28 @@ pub async fn setup_and_tap_task(spawner: Spawner, shared: &'static Shared) { ControllerCore::start(core_mutex, spawner); } + ``` This starts out by allocating and creating the components that we will need, starting with the aforementioned `BatteryAdapter`, which we will implement in a moment, and creating the `BatteryWrapper` with this in mind. -It then creates the battery, charger, sensor, and fan components. You may notice that in doing so we create both a DEVICE and a POLICY_DEVICE for each. Both of these Device type wrappers are identical per component. One is used to create the controller, and one is used to register the device with the service. Since these are tied by Id designation, they are equivalent, and since we can't pass a single instance twice without incurring a borrow violation, we use this technique. +It then creates the battery, charger, sensor, and fan components. You may notice that in doing so for the battery and charger, we create both a DEVICE and a POLICY_DEVICE for each. Both of these Device type wrappers are identical per component. One is used to create the controller, and one is used to register the device with the service. Since these are tied by Id designation, they are equivalent, and since we can't pass a single instance twice without incurring a borrow violation, we use this technique. + +For the sensor and fan, we wrap our controllers in the TS_SENSOR and TS_FAN allocated statics. These wrappers contain the controllers and device ids, and the profile configurations needed to enact the ODP prescribed thermal-service handling. We then init the thermal service and use these wrappers to register these thermal components into it. -This brings us to the construction of the `ControllerCore`. Here, we give it all of the components, plus the comm channels that were shared from our earlier allocations in `entry.rs`. We also see here we are passing references to a new channel `integration_channel`, and the `SystemObserver`, neither of which we have created yet. +Note the additional items at module-scope to support this. We call upon the `ts::impl_sensor_task!` and `ts::impl_fan_task!` macros that will generate a task function we can spawn to to support these services. We also have defined helper functions that allow us to map our policy configuration values to the ODP policy structures for these services. + +This brings us to the construction of the `ControllerCore`. Here, we give it all of the components, plus the comm channels that were shared from our earlier allocations in `entry.rs`. We also see here we are passing references to a new channel `interaction_channel`, and the `SystemObserver`, neither of which we have created yet. Once we get our `ControllerCore` instance created, we wrap it into a mutex that we stash into a `StaticCell` so that we have portable access to this structure. The remainder of the `setup_and_tap_task` proceeds with registration and then spawning the execution tasks. + +> ----- +> ### Mind the namespaces +> +> Note the `use` statements and the namespaces for similarly named types (`Device`, `ChargerId`, `register_device`, `register_charger`, `register_fan`, etc.) +> The ODP supported services generally use type-specific but similar sounding structures. +> It is easy to include the wrong 'version' of a named struct from a different namespace. +> If you apply these incorrectly, you will get unexpected results. +> +> ---- diff --git a/guide_book/src/how/ec/integration/8-battery_adapter.md b/guide_book/src/how/ec/integration/8-battery_adapter.md index 2599b61..e357ff9 100644 --- a/guide_book/src/how/ec/integration/8-battery_adapter.md +++ b/guide_book/src/how/ec/integration/8-battery_adapter.md @@ -304,3 +304,6 @@ impl Controller for BatteryAdapter { ``` As noted, the `BatteryAdapter` is nothing more than a forwarding mechanism to direct the trait methods called by the battery service into our code base. We pass it the reference to our `core_mutex` which is then used to call the battery controller traits implemented there, in our `ControllerCore` code. +Note that we might also have chosen to direct all but the `get_static_data` / `get_dynamic_data` trait methods of `BatteryAdapter` directly to the `MockBatteryController`, since the `ControllerCore` is going to simply forward them there anyway. + + diff --git a/guide_book/src/how/ec/integration/9-system_observer.md b/guide_book/src/how/ec/integration/9-system_observer.md index ccdea0d..9373866 100644 --- a/guide_book/src/how/ec/integration/9-system_observer.md +++ b/guide_book/src/how/ec/integration/9-system_observer.md @@ -1,7 +1,7 @@ # The SystemObserver -Before we can construct our `ControllerCore`, we still need a `SystemObserver` and and `InteractionChannelWrapper` to be defined. +Before we can construct our `ControllerCore`, we still need a `SystemObserver` and `InteractionChannelWrapper` to be defined. The `SystemObserver` is the conduit to display output and communicates with a `DisplayRenderer` used to portray output in various ways. The renderer itself is message-driven, as are user interaction events, so we will start by going back into `entry.rs` and adding both the `DisplayChannelWrapper` and `InteractionChannelWrapper` beneath the other "Channel Wrapper" definitions for Battery, Charger, and Thermal communication. ```rust @@ -147,7 +147,7 @@ fn diff_exceeds(cur: &DisplayValues, prev: &DisplayValues, th: &Thresholds) -> b (cur.draw_watts - prev.draw_watts).abs() >= th.load_w_delta || (cur.soc_percent - prev.soc_percent).abs() >= th.soc_pct_delta || (cur.temp_c - prev.temp_c).abs() >= th.temp_c_delta || - (th.on_fan_change && cur.fan_level != prev.fan_level) + (th.on_fan_change) } ``` From fcec474d090a53c6d7339028dc5c384fbc20108c Mon Sep 17 00:00:00 2001 From: Steven Ohmert Date: Thu, 25 Sep 2025 12:00:54 -0700 Subject: [PATCH 6/6] Fix broken summary index --- guide_book/src/SUMMARY.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/guide_book/src/SUMMARY.md b/guide_book/src/SUMMARY.md index 3d42542..1187159 100644 --- a/guide_book/src/SUMMARY.md +++ b/guide_book/src/SUMMARY.md @@ -92,10 +92,9 @@ - [Standard Build](./how/ec/thermal/5-standard.md) - [Thermal Project](./how/ec/thermal/6-project.md) - [Thermal Traits](./how/ec/thermal/7-traits.md) - - [Thermal Values](./how/ec/thermal/8-values.md) - - [Service Prep](./how/ec/thermal/9-service_prep.md) - - [Thermal Service](./how/ec/thermal/10-service_registry.md) - - [Tests](./how/ec/thermal/11-tests.md) + - [Device and Controller](./how/ec/thermal/8-device_and_controller.md) + - [Behavior](./how/ec/thermal/9-behavior.md) + - [Tests](./how/ec/thermal/10-tests.md) - [Integration](./how/ec/integration/1-integration.md) - [Move Events](./how/ec/integration/2-move_events.md) - [Better Alloc](./how/ec/integration/3-better_alloc.md)