Skip to content

Latest commit

 

History

History
1583 lines (1322 loc) · 68.4 KB

File metadata and controls

1583 lines (1322 loc) · 68.4 KB

API Reference

The complete public surface: Rust crates, the C ABI, the Python package, and the host contract. The generated per-crate index is api-index.md (regenerate with python tools/gen_api_index.py).

Crate roles at a glance (ml vs ai vs nn)

Crate Role Typical caller
nobro_nn from-scratch NN inference blocks (dense/conv/LSTM/attention, int8 kernels) firmware running a model
nobro_ml classic/TinyML utilities (quantization, filters, anomaly, federated averaging) firmware feature pipelines
nobro_ai model governance: manifests + checksums for already-trained models, cloud-session state machines deployment + host tooling

Crates and contracts

This manual summarizes the public crates and the core contracts used by applications, adapters, and host tooling.

Arduino provider facade

NobroArduinoProviders.h supplies fixed-state wrappers for the selected Arduino board package: ArduinoClock, ArduinoDeadline, ArduinoAdc, ArduinoPwm, ArduinoI2c, ArduinoSpi, and ArduinoByteIo. These wrappers do not replace a vendor core or copy its register drivers; they give applications one bounded call shape while the installed board package continues to own pin routing, interrupts, and peripheral setup. The UNO R4 uses this path for ADC/PWM/I2C/SPI; those facade calls are not native nobro-hal provider claims. The separate native RA4M1 composition implements only timebase/deadline/USB. Its 48 MHz DWT clock must be sampled during active execution at least once per approximately 89-second counter wrap and is not a sleep-stable timebase. Its 24-bit SysTick one-shot rejects delays above approximately 349 milliseconds. A stronger always-on clock and chained alarm are still pending.

Plain-C Tier-C facade

nobro_app.h exposes an allocation-free task graph for C firmware linked with the Tier-C libnobro.a:

nobro_task("imu", HZ(100), imu_step);
nobro_task("control", HZ(50), control_step);
nobro_wire("imu", "control", 8);
nobro_run();

The default task is a periodic driver. nobro_task_with() accepts one nobro_task_options_t record for control/service role and timing overrides. NOBRO_APP(configure) generates the existing init/poll ABI callbacks. Registration is fixed at eight tasks and eight wire relationships; all failures return stable nobro_result_t values. nobro_run() reuses Rust AppGraph validation, and late dispatch preserves phase while incrementing nobro_skipped_releases(). A wire declaration is graph/mailbox metadata, not a payload transport. See the C binding guide for the complete specimen and current busy-poll timing limitation.

Python app authoring

NobroApp exposes the same task/wire vocabulary to host tests:

from nobro_rtos import HZ, NobroApp

app = (NobroApp("rover", board="nrf52840-nosd")
       .task("motor", HZ(200), role="control")
       .task("imu", HZ(100))
       .wire("imu", "motor", 8))
report = app.run(50_000)
app.write_json("app.json")

run() and simulate() execute deterministic, bounded host callbacks. write_json() emits the strict nobro-app-v1 graph accepted by nobro firmware; callbacks are omitted. The firmware CLI validates JSON and does not evaluate Python source. Generated firmware is native Rust using the existing admission path. Current firmware generation supports the nRF52840 SoftDevice and no-SoftDevice profiles. Wire capacity is retained as topology metadata, not exposed as a Python payload transport.

Crate Overview

Crate Purpose
nobro-kernel Manifest, admission, runtime, quota, capability, scheduler, IPC, alarms, recovery, health, and reports
nobro-hal Board profiles, platform traits, nRF52840 implementation, leases, timers, PWM, bus, and event capture
nobro-sal Stable service traits for adapters, apps, AI inference, and edge bridges
nobro-nn Heap-free MCU inference blocks: dense, int8 dense, 1-D/2-D convolution, pooling, recurrent cells, and attention
nobro-ml Heap-free DSP/ML utilities: anomaly stats, fusion, gesture detection, KWS audio features, model scheduling
nobro-secure Secure boot decisions, attestation, rollback guard, key store, tamper seal, and audit log
nobro-net Mesh routing, secure links, OTA chunking, store-forward queues, fleet OTA rollout planning
nobro-wireless Bounded link contracts, protocol descriptors/helpers, and implemented WirelessBackend adapters such as MFRC522 and CC2530
nobro-camera Frame leases, capture admission, stream backpressure, recovery, and diagnostics
nobro-host Host-side constants, report layouts, labels, and status helpers

Version 0.3.0 API migration

The changes in this section are the migration boundary from the historical v0.2.0 source milestone to the v0.3.0 public package contract.

USB

nobro-usb returns the public MountedUsb wrapper from try_mount() and mount(). The concrete NrfUsbdCdc, UsbSerialJtagCdc, and RaUsbfsCdc implementation types are private. Applications that imported or constructed a concrete backend must remove that type annotation or constructor, select one exact backend-* feature, and call nobro_usb::try_mount(&config). try_mount() reports AlreadyMounted or UnsupportedConfig; it performs descriptor preflight before the permanent singleton claim and before hardware access. mount() remains as a compatibility wrapper and panics on those errors.

The former ambiguous backend-usb-serial-jtag feature is split into backend-usb-serial-jtag-esp32c3 and backend-usb-serial-jtag-esp32s3; downstream manifests must select the register map for their exact chip. Placeholder backend identity constants that had no selectable implementation are removed; diagnostics identify only backends that can actually be mounted.

UsbConfig is a request, not an unconditional claim about the identity observed by the host. identity_policy() reports one of three behaviors: nRF descriptors use the requested identity, RA4M1 requires the exact exported RA4M1_USB_CONFIG, and the ESP32-C3/S3 fixed-function USB-Serial-JTAG controller ignores the requested identity. config_supported() checks only whether a request is accepted; for a controller-fixed identity, acceptance does not mean the requested VID, PID, or strings are advertised.

The count-only UsbStack::write and read methods remain for compatibility. The newly added flush method has a fail-closed false default so older third-party implementations remain source-compatible without claiming that unknown private buffers are drained. New generic code should use try_write, try_read, and try_flush, or the bounded MountedUsb::write_all, read_available, and flush_pending conveniences. These distinguish ordinary non-blocking backpressure from typed UsbBackendError failures such as ControllerTimeout.

UsbStack::configured() now describes only the currently observed usable link; reset, suspend, watchdog expiry, or disconnect makes it false. It is no longer a historical "configured at least once" latch. Applications that need session history must track it separately.

UsbStack::force_reenumeration() explicitly detaches and reattaches the current application device session when the backend supports that operation. It is for a rate-limited recovery policy after enumeration stops making progress, not for routine host suspend. A successful return means that recovery was initiated; keep calling poll() and wait for configured() to become true before using the data endpoints. The nRF USBD backend currently implements this generic operation. The selectable RA4M1 and ESP backends retain the default Unsupported result; board-specific routing or recovery APIs are separate. On nRF, absence of VBUS or a bounded controller-wake failure is reported as a typed backend error. This operation neither enters a bootloader nor changes firmware. A bootloader has its own USB identity, independent of the application identity requested through UsbConfig.

The fallible inherent convenience formerly named MountedUsb::flush() is now flush_pending(). This removes its return-type/name collision with UsbStack::flush() -> bool; update callers that expected Result<(), UsbIoError>.

CdcState and the RA4M1 backend's public Stage gain Suspended; CdcState, Stage, and UsbIoError become #[non_exhaustive]. That is an intentional source break for downstream exhaustive matches: add a wildcard arm and fail closed for states or errors the application does not understand. UsbIoError also gains the Backend case. This policy lets later controller states and typed faults be added without repeating the same exhaustive-match break.

Wireless

TxContract removes the public priority and max_attempts fields; construct it with TxContract::by(deadline_us) or set only deadline_us. ManagedLink::send_at now checks one immediate attempt against that deadline. Scheduling priority belongs to the scheduler, and retry attempts/state belong to the caller.

Protocol adds Ieee802154Raw, so downstream exhaustive Protocol matches must add that arm (or a wildcard). LinkDiagnostics adds public rx_rejected, so downstream struct literals must initialize it; Default remains available. The CC2530 descriptor is now IEEE802154_RAW with the 127-byte PSDU limit after explicit initialization. ZIGBEE_APS remains catalog metadata and is not an implemented Zigbee join/NWK/APS stack.

Neural Network API

nobro-nn is inference-side and no_std: callers pass all input, output, and scratch buffers explicitly. Dense layers use [OUT][IN] weights. 2-D convolution and pooling use NHWC tensors without a batch dimension, which keeps camera and sensor-grid models easy to inspect on small MCUs:

let mut out = [0.0f32; 4];
nobro_nn::conv2d_valid(
    &image_3x3x1,
    3, 3, 1,
    &kernel_2x2x1,
    2, 2, 1,
    &[0.0],
    &mut out,
);

For quantized models, dense_int8 and conv2d_valid_i8 accumulate into i32. The caller can then fuse activation, argmax, or requantization according to the model contract.

nobro-ml provides the fixed keyword-spotting feature contract used by the yes/no example model. kws_log_energy_features turns up to one second of 16 kHz mono PCM into 120 normalized features (15 x 8) without heap allocation:

let mut features = [0.0f32; nobro_ml::KWS_FEATURES];
assert!(nobro_ml::kws_log_energy_features(&pcm_i16, &mut features));

Wireless Domain API

nobro-wireless keeps link identity as data and lets concrete transports implement the small WirelessBackend data-plane trait (descriptor, link state, send, receive, and recovery). ManagedLink adds traffic accounting and checks whether one immediate send attempt is submitted by TxContract::deadline_us. It does not schedule by priority or retry a failed attempt: priority is scheduler-owned and retry state is caller-owned.

The crate currently includes concrete MFRC522 and CC2530 implementations plus protocol helpers such as BleAdvBuilder. After explicit initialization, Cc2530<ByteIo> is a raw IEEE 802.15.4 PSDU transport with the 127-byte PHY limit; it does not join a Zigbee network or implement APS. ZIGBEE_APS remains descriptor metadata, not evidence of an implemented Zigbee stack. A descriptor or packet builder by itself is not board support.

WifiStack and BleStack provide separate lifecycle contracts beneath ManagedLink/WirelessBackend; MountedWifi and MountedBle return backend ownership when mounting fails. WiFi credentials borrow runtime storage, while BleEventQueue makes callback capacity explicit. Each identity reports stable MTU, queue, and GATT limits. These contracts do not themselves implement association, IP sockets, or a BLE controller. Configuration-priced UNO R4 WiFiS3 and Arduino-ESP32 C3 bindings exist under wireless/wifi/arduino-wifis3 and wireless/wifi/arduino-esp. Their Arduino facades copy scan results into caller storage, borrow runtime credentials, and expose scan/join/leave/quiesce/recovery through the selected board package. The ESP facade delegates to the pinned Arduino-ESP32 3.3.10 WiFi stack on ESP32, ESP32-C3, and ESP32-S3; the UNO R4 facade delegates to WiFiS3. Both vendor stacks remain synchronous and heap-using internally, so a post-call deadline miss is not hard cancellation. The exact C3 backend has repeated association, DNS, TCP, and lifecycle evidence plus a complete fixed/runtime price for four HTTP transactions/s. The exact UNO R4/WiFiS3 0.6.0 binding has the same lifecycle/data-plane evidence plus an RA-side/controller-image price for one HTTP transaction/s; controller-internal runtime resources remain unpriced. The exact controller release artifact separately establishes 1,180,064 B application flash, 64,628 B static RAM, and source minima of 22,288 B across three persistent application/USB task stacks. Other rates, boards, firmware versions, socket workloads, and shared-radio/WiFi-BLE compositions remain independently unpromoted. Additive WiFi and BLE instances must not be replaced by one global wireless feature.

The first concrete BLE bridge is wireless/ble/arduino-ble with NobroArduinoBLE.h. On UNO R4 WiFi it binds ArduinoBLE 2.1.0 to the board package's official HCIVirtualTransportAT/WiFiS3 modem path. It exposes one service, one characteristic, one logical connection, 20-byte values, fixed caller-owned events, and explicit mount/advertise/poll/respond/disconnect/quiesce/recover calls. ArduinoBLE still owns global HCI/GATT state and heap allocation. The facade supplies the HCIEND path omitted by ArduinoBLE 2.1.0 and bounds its cleared-service retain across remounts.

The exact binding has host tests, zero-disabled proof, BLE-only plus WiFi+BLE target builds, and physical GATT/disconnect/remount/recovery evidence. A subscribed link survived 15 WiFiS3 DNS/TCP transactions and then completed post-WiFi notification/readback in three cycles with no RA-side heap growth. Because WiFiS3 calls are synchronous and controller retained/transient heap, complete task/stack reservations, and CPU are not measured, the binding remains explicitly unpriced and does not claim preemptible GATT service during a blocking modem call.

The second concrete bridge is wireless/ble/arduino-esp with NobroArduinoEspBLE.h. It uses the BLE library bundled with Arduino-ESP32 3.3.10: classic ESP32 selects Bluedroid and ESP32-C3/S3 select NimBLE. The facade keeps one process-wide stack, service, characteristic, and four-event fixed ring; overflow returns NOBRO_STACK_QUEUE_FULL. NOBRO_ESP_BLE_DISABLED is zero-cost on all three target profiles. The exact ESP32-C3 binding has physical GATT, bounded quiesce/recovery, and admitted WiFi DNS/TCP coexistence evidence plus a workload-scoped incremental price. The exact classic ESP32 Bluedroid binding has two independent eight-cycle physical campaigns and a conservative whole WiFi+BLE composition price; a standalone classic wifi0 price and BLE-only increment are not inferred. ESP32-S3 and other workloads retain target-build evidence only.

RFID readers use the same discipline. SpiIo is the board-supplied SPI byte adapter, rfid_readers::MFRC522_SPI describes a common ISO 14443A reader, and Mfrc522<S> provides bounded request, anticollision, UID polling, and a WirelessBackend implementation:

let mut reader = nobro_wireless::Mfrc522::new(board_spi);
reader.init()?;
let uid = reader.poll_uid(4)?;
assert!(!uid.is_empty());

The backend allocates no heap memory, bounds polling, validates ISO 14443A BCC, and returns explicit RfidError values for bus, timeout, collision, protocol, and buffer failures.

Camera Domain API

nobro-camera keeps camera code small and composable: a backend lends a frame, while CameraPipeline enforces the caller's deadline, maximum frame size, frames/bytes per window, and in-flight limit. Storage, AI, and transport share the same diagnostics without owning the sensor driver. The matching C ABI is nobro_camera.h; Arduino users mount NiusCam through NobroNiusCam.h.

Security API

nobro-secure keeps the secure-boot decision separate from the unsafe, board-specific jump. The recommended fleet boundary is verify_signed_boot, which verifies a pinned Ed25519 key, signed image metadata, SHA-256 measurement, anti-rollback floor, and entry/stack policy before returning a VerifiedSignedImage. Its fields are private, so update and boot controllers cannot manufacture an unverified release token.

PersistentBootController commits stage, first-trial, confirm, and revert state through MonotonicBootStore; storage failures stop the boot decision. A board port supplies the durable store and the final jump. ProtectedKeyBackend similarly lets a platform authenticate without exporting key bytes, while AuthenticatedReportEnvelope is the integrity boundary for reports used in trust decisions.

The older SecureBoot::boot_plan HMAC path remains for per-device authentication and compatibility. It verifies image length, address range, entry/stack vector sanity, SHA-256 measurement, and anti-rollback version before returning a VerifiedBootPlan:

let policy = nobro_secure::BootVectorPolicy::cortex_m(
    0x1000,
    0x80000,
    0x2000_0000,
    0x2004_0000,
);
let plan = secure_boot.boot_plan(&boot_key, image, &manifest, policy)?;

tools/sign_firmware.py can emit a matching legacy HMAC manifest JSON:

python tools/sign_firmware.py app.bin --version 8 --load-addr 0x1000 --entry-addr 0x1101 --stack-top 0x20010000 --manifest-out _work\app.manifest.json

Transactional Database Persistence

nobro-storage::BlobStore stores one bounded byte image across two alternating flash pages. Payload and checksum are written before the commit marker, and mount selects the newest valid generation. A reset during erase or programming therefore exposes either the complete old image or the complete new image.

nobro-database::PersistentTable combines that transaction protocol with Table<V, N>'s stable RecordCodec. Callers supply a scratch buffer, keeping load and save allocation-free:

let mut persisted = nobro_database::PersistentTable::mount(board_flash);
let mut image = [0u8; 256];
let table = persisted.load::<Reading, 8>(&mut image)?;
persisted.save(&table, &mut image)?;

Kernel API

Manifest

SystemManifest<N> is a fixed-capacity list of ModuleSpec records. Each module declares:

  • id: stable module tag
  • criticality: best effort, user, driver, system, or hard realtime
  • requires: capability bits needed at runtime, including optional AI and bridge capabilities
  • owns: capability bits provided or exclusively owned by the module
  • memory: flash, RAM, and sample-pool budget
  • deadline: optional periodic contract
  • faults: retry and escalation thresholds

Use manifest validation before constructing runtime state:

let report = nobro_kernel::ManifestReport::from_result(
    &manifest,
    manifest.validate_profile(profile),
);
assert!(report.verify_checksum());
assert_eq!(report.valid, 1);

Python contract bundles mirror the same ownership discipline for host tooling: non-kernel capabilities must have a single owning module, and user-level modules cannot claim kernel-owned capabilities such as the timebase or host-report surface. Use check-bundle-matrix to validate module naming, capability ownership, AI/ROS descriptor uniqueness, hard-realtime deadlines, startup dependency errors, and JSON roundtrip stability. They also carry optional startup dependencies. The Python plan_startup helper uses the same contract shape as the Rust startup graph, rejects unknown modules, duplicate dependencies, and cycles, and emits a deterministic startup order for editor tasks and CI gates.

Boot Assembly

BootAssembly is a no-heap helper for firmware that wants less startup boilerplate without hiding the contracts. It builds a manifest from static module specs, applies startup dependencies, runs admission, constructs the runtime, boots it to Running, and keeps the manifest and admission reports:

let assembly = nobro_kernel::BootAssembly::<4, 4, 4, 4, 4, 4, 4, 4, 16>::build(
    &specs,
    &[nobro_kernel::StartupDependency::new(
        nobro_kernel::ModuleId::Sensor,
        nobro_kernel::ModuleId::Kernel,
    )],
    profile,
    nobro_kernel::FaultThresholds::DEFAULT,
    now_us,
)?;
assert_eq!(assembly.runtime.state(), nobro_kernel::SystemState::Running);

Use BootAssemblyError to preserve the failing phase: manifest validation, startup graph construction, admission, or runtime boot. Use build_with_failure when firmware should keep sealed manifest/admission reports after a failed boot assembly step:

let failure = match AppBoot::build_with_failure(
    &specs,
    &dependencies,
    profile,
    nobro_kernel::FaultThresholds::DEFAULT,
    now_us,
) {
    Ok(_) => unreachable!(),
    Err(failure) => failure,
};
assert!(failure.manifest_report.verify_checksum());

Use BootAssembly::reports() or BootAssemblyFailure::reports() when app code only needs to export the sealed startup reports:

let reports = failure.reports();
assert!(reports.manifest.verify_checksum());

Adapter preflight writes NOBRO_ADAPTER_COMPAT_REPORT before hardware-facing application work begins, so host tools can stop at the adapter stage when descriptors do not match the selected board profile.

Admission

Periodic declarations can separate first-release phase, period, and relative deadline without duplicating scheduler configuration:

let motor = nobro_kernel::TaskDecl::control("motor", 5_000)
    .phase_us(1_000)
    .deadline_us(4_000)
    .budget_us(400);

The fluent AppGraph builder remains the concise default. Firmware whose startup stack must not scale with graph capacity can put the same declarations in read-only storage and borrow them through GraphSpec:

use core::mem::MaybeUninit;
use nobro_kernel::{BuiltGraph, ChannelDecl, GraphSpec, TaskDecl};

const TASKS: [TaskDecl; 2] = [
    TaskDecl::periodic("imu", 10_000),
    TaskDecl::control("motor", 20_000).after("imu"),
];
const CHANNELS: [ChannelDecl; 1] = [ChannelDecl::new("imu", "motor")];

static mut BUILT: MaybeUninit<BuiltGraph<3, 2>> = MaybeUninit::uninit();

let built = GraphSpec::new(&TASKS, &CHANNELS).build_for_into::<3, 2>(
    nobro_kernel::SystemProfile::NRF52840_CORE,
    unsafe { &mut *core::ptr::addr_of_mut!(BUILT) },
)?;

GraphSpec uses the same duplicate-name, dependency, channel, manifest, startup-cycle, and profile validation as AppGraph; it changes storage placement, not admission semantics.

When the expanded graph is needed only during startup, start_executor performs graph validation, runtime admission, boot, task registration, and schedulability sealing in one call. Only the derived manifest and startup nodes are temporary stack scratch; task metadata and identities are regenerated directly from the const declaration, and no BuiltGraph is retained:

use nobro_kernel::{
    ContainmentPolicy, FaultThresholds, LeanKernelExecutorCell,
};

static EXECUTOR: LeanKernelExecutorCell<2, 3, 4> =
    LeanKernelExecutorCell::new();

let graph = GraphSpec::new(&TASKS, &CHANNELS);
let executor = graph.start_executor(
    &EXECUTOR,
    nobro_kernel::SystemProfile::NRF52840_CORE,
    FaultThresholds::DEFAULT,
    ContainmentPolicy::Cooperative,
    now_us,
)?;
let motor = graph.module_of("motor").unwrap();

Use build_for_into instead when the expanded manifest, startup plan, labels, or reactor bindings must remain inspectable after startup, or when the derived manifest/startup scratch should live in static storage rather than increasing startup stack. start_executor trades retained graph RAM for a smaller capacity-sized temporary frame; choose from measured board-specific stack evidence.

The same values reach the manifest, shared build/runtime admission core, and executor. Invalid phase uses stable diagnostic NOBRO-E015. Periods are limited to MAX_WRAP_SAFE_INTERVAL_US (0x7fff_ffff us) because Nano's 32-bit allocation-free clock comparison requires an unambiguous half-range.

Advanced Cortex-M applications may opt into interrupt-domain admission with nobro_admission::admit_with_interrupts. InterruptContract declares logical priority, period/deadline, execution, exception-stack bytes, and only bounded IsrOperations. Platform profiles reject reserved priorities (including S140 reservations), account nested stack use, and add every ISR's interference to cooperative task response bounds. Multiple sources may share one logical priority; admission conservatively charges equal-priority sources as mutual interference because vector-order metadata is not part of the contract. Arbitrary ISR callbacks are intentionally absent.

The nobro-kernel/preemptive feature adds InterruptHandoff and SliceController; nobro-hal/cortex-m-slice adds the nRF PSP/PendSV mechanism. Both are opt-in. P-SLICE owns separate stacks but is not an MPU or privilege boundary. Start a controller with start_next_at(now_us, sentinel) before entering thread mode; this arms the budget sentinel before user work can hog. CortexMSliceSwitch::start(record, pendsv_logical_priority, ceiling) validates an explicit PendSV priority at or below the selected BASEPRI ceiling (logical 7 is the portable nRF choice). This ensures a context switch cannot split a process-wide critical-section transaction. A budget overrun outside a critical section is suspendable; one inside a section remains pending until the section exits and must escalate to the watchdog if it never exits. on_budget_interrupt queues the PendSV request without changing the current task; call commit_pending_switch_at(now_us, sentinel) only after the port has actually committed the PSP switch. The port preserves BASEPRI per PSP context. SliceTask::allows_fpu(true) raises the stack floor by 136 bytes: 72 bytes of hardware extended frame plus 64 bytes for S16-S31.

The nRF board package also supplies the process-wide critical-section implementation. It uses BASEPRI 3 for the no-SoftDevice profile and BASEPRI 6 for S140, so admitted deadline/watchdog priorities remain serviceable while kernel, HAL, USB, adapter, and portable-atomic users share one lock contract. Do not also enable Cortex-M's critical-section-single-core PRIMASK provider. Interrupts above the ceiling may touch only lock-free handoff state; using a critical-section mutex there violates the priority-ceiling contract.

AdmissionController composes manifest validation, startup ordering, quota seeding, and capability grant construction:

let plan = nobro_kernel::AdmissionController::admit::<8, 8, 8>(
    &manifest,
    &startup_nodes,
    profile,
)?;

Admission failures are reported through AdmissionReport, using stable error codes mirrored in nobro-host and host/nobro-host-contract.json.

Capability Trace

CapabilityTrace<N> records privileged operations only after the module passes the same CapabilityGrantTable authorization used by the runtime. The trace is a fixed-size ring buffer: it preserves deterministic replay order for retained records, counts overwritten records, and never allocates.

let mut trace = nobro_kernel::CapabilityTrace::<8>::new();
trace.record_authorized(
    &grants,
    nobro_kernel::CapabilityTraceInput::new(
        nobro_kernel::ModuleId::Sensor,
        nobro_kernel::Capability::Bus0,
        nobro_kernel::CapabilityTraceOp::Read,
        now_us,
    )
    .args(0x68, 6),
)?;

let mut replay = [nobro_kernel::CapabilityTraceRecord::EMPTY; 4];
let copied = trace.copy_replay(
    nobro_kernel::CapabilityReplayScope::exact(
        nobro_kernel::ModuleId::Sensor,
        nobro_kernel::Capability::Bus0,
    ),
    &mut replay,
);

General modules do not call raw protected runtime primitives: those methods are crate-private and ModuleCtx records attempt/completion or fault around the operation. The foreign host boundary follows the same rule. An absent retained record is meaningful for these governed operations when dropped() == 0; trusted platform code is outside this module-security trace.

Runtime

Runtime is the fixed-capacity control plane for admitted applications. It owns:

  • module state via ModuleRuntimeGuard
  • mailbox IPC
  • software alarms
  • key-value configuration
  • quota reservations and releases
  • recovery coordination
  • degraded-mode reports
  • event-log and health reports

Alarm, KV, retained event-log, and capability-trace capacities may be zero. Their operations then return the normal fixed-capacity error (or retain no records), so unused services consume no slot storage and do not require a separate runtime implementation. Mailbox IPC remains mandatory. For the common case, LeanRuntime<MODULES, MAILBOX> and LeanKernelExecutorCell<TASKS, MODULES, MAILBOX> keep admission, quotas, mailbox IPC, health/recovery, watchdogs, and object accounting while selecting zero capacity for those optional stores:

use nobro_kernel::LeanKernelExecutorCell;

static EXECUTOR: LeanKernelExecutorCell<5, 6, 4> =
    LeanKernelExecutorCell::new();

Module-facing operations use the identity-bound context:

ctx.send(receiver, kind, arg0, arg1)?;
ctx.schedule_once(alarm_id, delay_us)?;
ctx.kv_set(key, value)?;
let sample = ctx.pool_alloc(kind, len, deadline_us)?;

Use watchdog_expired_count(now_us) for a non-mutating liveness precheck. Use sweep_watchdogs(now_us) when the runtime should route expired modules through recovery and update missed heartbeat counters. Use record_error_with_plan or record_watchdog_expired_with_plan when a supervisor needs the updated health/lifecycle state and the next bounded recovery steps in one result:

let planning = runtime.record_error_with_plan::<4>(
    nobro_kernel::ModuleId::Sensor,
    nobro_kernel::KernelError::SensorReadFail,
    now_us,
    nobro_kernel::RecoveryPlanPolicy::DEFAULT,
)?;
assert!(planning.plan.deadline_us >= now_us);

Use apply_recovery_step(step, now_us, hooks) when a firmware loop or host simulator dispatches a recovery step. ModuleLifecycleHooks is the executable adapter boundary for notify, retry, quiesce, stop/start, self-test, heartbeat, and resume. A hook error is returned as RuntimeError::ModuleHook, and the runtime does not advance the module to active state after a failed operation.

The check-watchdog-matrix CLI validates non-mutating liveness prechecks, expiry mutation, heartbeat reset, multi-module expiry, and capacity errors. The check-scheduler-matrix CLI validates on-time ticks, tolerated early/late jitter, missed deadlines, 32-bit time wraparound, counter reset, and invalid scheduler configuration.

Bounded Async Deadline Guard

TimerQueue::with_deadline and the free with_deadline helper wrap a caller-pinned future with an explicit (phase_us, period_us, deadline_us) contract. The wrapper registers the absolute compare deadline in the fixed timer queue and polls the deadline before the inner future after wake-up. If the deadline fires first, the future resolves to Err(DeadlineFault); use DeadlineFault::health_fault() to route the miss through the normal health/recovery path instead of accepting a silent late result.

let mut read = core::future::ready(7u8);
let value = nobro_kernel::with_deadline(
    &timers,
    0,
    1_000,
    250,
    core::pin::Pin::new(&mut read),
).await?;
assert_eq!(value, 7);

Invalid contracts and exhausted timer capacity also fail closed with a DeadlineFault. Hardware-completion wakers and provider-specific DMA/PPI sleep-through operations are separate backend features; this API is the portable async contract boundary.

For multi-priority async designs, attach each reactor domain to the graph task that drives it, then validate the domains and channels before constructing the concrete reactors:

let graph = nobro_kernel::AppGraph::<2>::new()
    .task(nobro_kernel::TaskDecl::control("control-reactor", 5_000)
        .reactor_domain(0))?
    .task(nobro_kernel::TaskDecl::service("telemetry-reactor", 100_000)
        .reactor_domain(1)
        .after("control-reactor"))?;
let built = graph.build::<3>()?;

let control = nobro_kernel::ReactorDomainContract::new(0, 0)
    .task_slots(4)
    .timer_slots(2)
    .fuel_per_cycle(4);
let telemetry = nobro_kernel::ReactorDomainContract::new(1, 3)
    .task_slots(8)
    .timer_slots(4)
    .fuel_per_cycle(8);

let plan = built.admit_reactor_domains::<2, 1>(
    [Some(control), Some(telemetry)],
    [Some(nobro_kernel::ReactorChannelContract::new(0, 1, 4))],
)?;
assert_eq!(plan.reactor.cross_domain_len, 1);

let priorities = plan.reactor.bind_interrupt_priorities(
    [
        Some(nobro_kernel::ReactorPriorityBinding::new(0, 3)),
        Some(nobro_kernel::ReactorPriorityBinding::new(1, 6)),
    ],
    nobro_kernel::InterruptProfile::NRF52840_BARE,
)?;
let control_irq = nobro_hal::CompletionInterruptPriority::new(
    priorities.binding(0).unwrap().logical_priority,
)?;
let telemetry_irq = nobro_hal::CompletionInterruptPriority::new(
    priorities.binding(1).unwrap().logical_priority,
)?;

static CONTROL_CORE: nobro_kernel::AsyncCore<4> = nobro_kernel::AsyncCore::new();
static CONTROL_TIMERS: nobro_kernel::TimerQueue<2> = nobro_kernel::TimerQueue::new();
static TELEMETRY_CORE: nobro_kernel::AsyncCore<8> = nobro_kernel::AsyncCore::new();
static TELEMETRY_TIMERS: nobro_kernel::TimerQueue<4> = nobro_kernel::TimerQueue::new();
let reactors = plan.bind_runtime([
    Some(nobro_kernel::ReactorRuntimeDomain::new(
        0, &CONTROL_CORE, &CONTROL_TIMERS)),
    Some(nobro_kernel::ReactorRuntimeDomain::new(
        1, &TELEMETRY_CORE, &TELEMETRY_TIMERS)),
])?;

Pass the resulting priority tokens to nRF completion providers through PpiWakeRoute::acquire_with_priority or Spim0::acquire_with_priority. The HAL rejects logical priorities that could preempt the board's critical-section ceiling because these ISRs publish stored task wakers. The compatibility acquire constructors retain the board-safe default.

Each domain is driven by exactly one normal admitted reactor task; the graph link rejects unknown, missing, or duplicate domain drivers before runtime binding. bind_runtime also rejects capacity drift, duplicate/missing concrete domains, aliased cores or timer queues, and an urgency order that the graph-task criticalities would invert. Drive the kernel with run_cycle_with_reactors; inside its ordinary dispatch, call reactors.run_domain(ctx.module(), &mut concrete_reactor) to apply the domain's admitted fuel. The call rejects a reactor built over a different core. The kernel advances every domain timer queue, wakes each affected admitted module, and merges the earliest async deadline before idle. Concrete executors and futures stay application-owned; there is no allocator or hidden executor.

Cross-domain channels are surfaced in the plan so applications can choose a multi-waiter transport such as MpmcChannel instead of accidentally sharing a single-waker SPSC channel across priority domains. A cross-domain channel must declare at least two waiter slots.

MpmcChannel::recv().await returns Result<Option<T>, WaitError>: Ok(None) means the channel is closed and drained, while Err(WaitersFull) means the admitted receiver-waiter bound was exhausted. send().await returns an MpmcSendError<T> that distinguishes closure from waiter exhaustion and returns ownership of the undelivered value. Dropping either pending future removes its parked waker immediately. For non-blocking producers, try_send_checked distinguishes retryable Full(value) from Closed(value); the older try_send remains the concise form when that distinction is unnecessary.

TaskGroup::cancelled().await likewise returns Result<(), WaitError>. Dropping a pending cancellation future releases its waiter immediately, and an out-of-contract extra waiter receives WaitersFull instead of parking forever.

Executor Power And Structured Faults

KernelExecutor::run_cycle accepts a PowerPlatform implementation. When no work is due, it programs the absolute wake deadline and the exact ready-group mask before entering the mode chosen by ExecutorPower. A compare ISR publishes that bounded mask through PowerPlatform::take_deadline_releases(); the normal executor cycle drains it automatically. A custom provider can also call accept_isr_releases() explicitly. Early, stale, duplicate, and out-of-range bits are rejected. Every completed poll automatically charges measured active time to its module's energy profile. Executor suspend/resume methods call fallible peripheral hooks before committing module state.

Call SystemProfile::wake_latency_us() for manifest/build admission or KernelExecutor::set_wake_latency_us() before seal() for a dynamic executor. This is a measured compare-wake-to-dispatch upper bound, charged once per response; it is not inferred from a board name.

Use HealthFault when subsystem context matters. It combines KernelError with FaultContext { source, code, detail0, detail1 }. A FaultPolicy can retain state and receives the module plus updated health counters; HealthReport v2 exports the latest context.

EventLog exposes non-mutating capacity helpers such as is_full(), remaining_capacity(), latest_sequence(), and has_dropped_events(). Use them when deciding whether to export a diagnostic report, compact a host trace, or raise degraded-mode pressure without pushing another event. The check-event-log-matrix CLI validates fixed-ring capacity accounting, overwrite pressure, recent-record order, severity thresholds, zero-capacity drop accounting, and invalid input handling.

Python host tooling mirrors quota accounting and degraded-mode planning through QuotaLedgerSimulator and DegradePlannerSimulator. These helpers are for design review, VS Code tasks, CI checks, and package examples; realtime firmware still uses the Rust control plane. The check-quota-matrix CLI validates fixed-capacity registration, reserve, release, total-use, strict module identity, limit enforcement, underflow, and overflow paths. The check-degrade-matrix CLI validates flash, RAM, pool, module-limit, same-criticality, planner-capacity, and essential-module pressure paths. The check-startup-matrix CLI validates no-dependency, dependency-chain, fan-in/fan-out, dependency-impact, unknown-node, self-cycle, duplicate-edge, and cycle paths for startup graph construction. The check-boot-summary-matrix CLI validates all-pass, missing-stage, corrupt-checksum, failed-adapter, in-progress-stage, diagnostic-code, and status-count paths for boot report summaries. The check-bundle-matrix CLI validates contract bundle roundtrip, capability ownership, module naming, AI/ROS uniqueness, hard-realtime deadline, and startup dependency error paths. The check-report-matrix CLI validates fixed report status classes, checksum handling, failure labels, and decoded runtime, AI model, and ROS bridge fields. AiInvocationConstraints and preflight_ai_invocation validate AI inference admission before any model or endpoint is contacted. They check buffer sizes, scratch and arena RAM, non-zero local arena declarations, module capability declarations, route budget, stale snapshot policy, degraded fallback, unavailable routes, and endpoint circuit state. RuntimeDrillSimulator composes the same host-side planning and quota checks with fixed-ring event logging and recovery escalation, which makes it useful for reviewing a complete control-plane pressure scenario before writing board code. Its RecoverySummary output gives stable retry, notification, reboot, final state, and self-healing flags for automated review. The check-runtime-drill CLI wraps the same scenario in a pass/fail gate for disabled-module count, reboot count, and dropped event-log records. The check-recovery-matrix CLI validates deterministic ignore, retry, notify, reboot, OK-reset, fixed-plan execution, and output-buffer backpressure paths for self-healing review.

build_project_template emits contract-first starter templates for standalone SDK, Arduino, PlatformIO, Python host, and Python board bridge workflows. It returns an in-memory file manifest so editors, CI jobs, and package tools can decide where to write files without hiding the generated contract content. materialize_project_template writes that manifest only after validating each relative path, and it refuses to overwrite existing files unless the caller asks for overwrite behavior. validate_project_template checks the generated directory shape and validates nobro-contract.json, then verifies generated VS Code task metadata for the detected target. This gives host tools a quick onboarding gate without building firmware or contacting a board. The check-project CLI returns non-zero when this validation fails. repair_project_template can rebuild stale or missing .vscode/tasks.json metadata for a detected starter target without rewriting application files or contracts. The check-starter-templates CLI materializes and validates every supported starter target in a temporary directory, then removes the generated files. Task validation checks the expected command arguments as well as labels, so a stale task cannot keep the right name while calling the wrong host tool. Starter templates also include VS Code task metadata for that same project check. Python host templates add runtime drill, runtime drill gate, AI route, AI route matrix, AI preflight matrix, ROS preflight matrix, recovery matrix, watchdog matrix, scheduler matrix, event log matrix, quota matrix, degrade matrix, startup matrix, boot summary matrix, bundle matrix, and report matrix gate tasks. Python board bridge templates add an offline bridge smoke task for MicroPython, CircuitPython, and mPython-style development. Host tooling can also run sample-startup to print a JSON startup dependency plan for the reference runtime module set, or check-startup-matrix to verify startup graph edge cases before adding board-specific adapters. Rust firmware can call StartupGraph::dependency_impact to compute the transitive modules affected by a faulted dependency before building a recovery adapter action sequence:

let impact = startup.dependency_impact::<4>(nobro_kernel::ModuleId::Hal)?;
for module in impact.affected.iter().take(impact.affected_count).flatten() {
    let _ = module; // Quiesce affected modules before restarting the root.
}

check-boot-summary-matrix verifies first-diagnostic priority, diagnostic-code layout, checksum corruption, failed-adapter labels, in-progress reports, and per-status counts for the same host report path. check-report-matrix keeps the individual fixed-report decoders locked to stable status and domain-field semantics.

Scheduler

Scheduler tracks deadline ticks, max jitter, and deadline misses without heap allocation. The default tick period is 20,000 us. Use set_jitter_tolerance_us() to tune the miss threshold for a board profile, host simulation, or timing source, and use stats() when exporting scheduler health to diagnostics:

nobro_kernel::Scheduler::reset_stats();
nobro_kernel::Scheduler::set_jitter_tolerance_us(25);
let stats = nobro_kernel::Scheduler::stats();
assert_eq!(stats.jitter_tolerance_us, 25);

Use check-scheduler-matrix before packaging to verify the host mirror for on-time ticks, tolerated early/late jitter, deadline misses, wraparound, reset, and invalid-configuration paths.

Recovery

RecoveryCoordinator routes faults through health counters, lifecycle state, actions, and event records. Recovery is module-scoped by default; global reset policy should stay outside hot-path adapters.

RecoveryPlan<N> turns a RecoveryOutcome into ordered fixed-capacity steps with due times, per-step budgets, and a total deadline:

let outcome = nobro_kernel::RecoveryOutcome {
    module: nobro_kernel::ModuleId::Sensor,
    error: nobro_kernel::KernelError::SensorReadFail,
    action: nobro_kernel::Action::RebootModule,
    state: nobro_kernel::SystemState::Recovering,
};
let plan = nobro_kernel::RecoveryPlan::<4>::from_outcome(
    outcome,
    now_us,
    nobro_kernel::RecoveryPlanPolicy::DEFAULT,
)?;
assert_eq!(plan.len, 4);

When a rebooted module is a shared dependency, feed startup impact data into the planner so dependent modules are quiesced before the root restart and resumed in startup order afterward:

let impact = startup.dependency_impact::<4>(nobro_kernel::ModuleId::Bus)?;
let plan = nobro_kernel::RecoveryPlan::<8>::from_outcome_with_impact(
    outcome,
    &impact,
    now_us,
    nobro_kernel::RecoveryPlanPolicy::DEFAULT,
)?;

Use RecoveryPlanPolicy to tune notify, retry, restart, verification, resume, and maximum total recovery budgets. Capacity and budget failures are explicit errors, so self-healing can be reviewed before being attached to board-specific restart or power-control code. RecoveryStormPolicy sets a bounded cooldown for identical module/error/action work. Health and fault counters continue to advance, while duplicate event and lifecycle work is coalesced; RecoveryOutcome::coalesced and suppressed_faults(module) expose the decision. Coalesced outcomes cannot be converted into duplicate recovery plans. Both manifest-level and runtime-global FaultThresholds are validated: notification must be nonzero and reboot escalation cannot precede notification. Invalid global thresholds fail runtime construction as RuntimeError::FaultThreshold before any module state is activated. Runtime helpers return RecoveryPlanning<N>, which pairs the committed RecoveryOutcome with the generated plan. Use Runtime::record_error_with_plan_and_impact or Runtime::record_watchdog_expired_with_plan_and_impact when the caller already has startup impact data for a shared dependency. The planner validates that the impact root matches the recovery outcome module before emitting dependent-module steps. Use HotReloadPlan and Runtime::reload_module for bounded module-slot replacement. The runtime suspends the module, releases registered resources, then requires ModuleReloadHooks to unmount, mount the requested revision, self-test, verify a heartbeat, and resume. Kernel and disabled-module requests are rejected. Hook failure is fail-closed: the module remains non-active. HAL or adapter code supplies both the module-slot hooks and a LeaseReleaser, so board-specific mechanics stay outside the kernel:

struct HalLeaseReleaser;

impl nobro_kernel::LeaseReleaser for HalLeaseReleaser {
    fn release_all_for_owner(&mut self, owner: u8) -> usize {
        // Bridge to the selected HAL lease backend.
        let _ = owner;
        0
    }
}

let mut leases = HalLeaseReleaser;
let outcome = runtime.reload_module::<5, _, _>(
    nobro_kernel::ModuleReloadRequest::new(
        nobro_kernel::ModuleId::Sensor,
        7,
        3,
        now_us,
        nobro_kernel::HotReloadPolicy::DEFAULT,
    ),
    &mut leases,
    &mut module_slot,
)?;
assert_eq!(outcome.plan.len, 5);

Use next_due, due_count, remaining_count, and copy_due to inspect time-ready recovery steps from a firmware loop or host simulator without mutating the plan or executing board-specific actions. Use RecoveryPlanExecution<N> when a loop needs to advance the plan without replaying already-dispatched work:

let mut execution = nobro_kernel::RecoveryPlanExecution::from_plan(plan);
let empty = nobro_kernel::RecoveryStep::new(
    nobro_kernel::ModuleId::Kernel,
    nobro_kernel::RecoveryStepKind::Observe,
    0,
    0,
);
let mut due = [empty; 2];
let dispatch = execution.dispatch_due(now_us, &mut due);
for step in due.iter().take(dispatch.dispatched) {
    runtime.apply_recovery_step(*step, now_us, &mut lifecycle_hooks)?;
}

The execution cursor owns no heap memory, uses caller-owned output buffers, and reports remaining steps, next due time, consumed budget, overdue work, and completion status. Pair RecoveryPlanExecution with Runtime::apply_recovery_step to keep ordered dispatch, executable platform actions, and module-state bookkeeping together.

Network API

nobro-net provides no-heap mesh primitives for routing, secure links, store-forward delivery, owned OTA image assembly, and fleet rollout planning. OtaImageAssembler<BYTES, CHUNKS> validates image/chunk geometry, owns out-of-order payloads, reports the first hole, and returns the image only after complete SHA-256 verification. FleetOtaOrchestrator<N> stages OTA updates as canary-first waves, enforces a maximum number of active updates, blocks rollout when fleet health falls below policy, and rolls failed nodes back without allocating:

let mut fleet = nobro_net::FleetOtaOrchestrator::<4>::new();
fleet.register(nobro_net::FleetOtaNode::new(1, 1))?;
fleet.register(nobro_net::FleetOtaNode::new(2, 1))?;

let wave = fleet.stage_next_wave(2, nobro_net::FleetOtaPolicy::DEFAULT)?;
assert_eq!(wave.phase, nobro_net::FleetOtaPhase::Canary);
fleet.mark_installing(1)?;
fleet.complete_node(1, true)?;

After the canary confirms, later calls stage normal rollout waves up to the configured parallelism. Failed nodes move through rollback and eventually blocked state according to the failure policy, giving host tooling or firmware a deterministic rollout controller before transport-specific OTA packets are sent.

stage_next_wave is useful for isolated policy simulation. Production update paths should call stage_verified_wave, which accepts only the private-field VerifiedSignedImage produced by nobro-secure's asymmetric verification.

HAL API

BoardDesc

BoardDesc exposes stable board facts:

  • platform and board identifiers
  • application flash origin
  • memory and module budgets
  • critical pins
  • servo timing defaults

Host-facing board data can be exported through BoardProfileReport.

Board profile catalog entries make identity, capacity, critical pins, and servo defaults reviewable without switching Cargo features:

for entry in nobro_hal::BOARD_PROFILES {
    let report = entry.report();
    assert!(report.verify_checksum());
}

BoardPackage combines BoardDesc with boot layout, flash region, RAM region, capacity, and critical pins:

let package = nobro_hal::Board::package();
assert_eq!(package.validate(), Ok(()));

BoardPackageError identifies invalid board data such as empty capacity, unaligned flash origin, empty memory regions, or duplicate critical pins.

When nobro-kernel is built with the hal-profile feature, admission limits can be derived directly from the active package:

let profile = nobro_kernel::SystemProfile::from_board_package(
    &nobro_hal::ACTIVE_BOARD_PACKAGE,
)?;

BoardPackageReport exports the same package contract as a fixed-layout host record:

let report = nobro_hal::BoardPackageReport::from_package(&nobro_hal::ACTIVE_BOARD_PACKAGE);
assert!(report.verify_checksum());

Board package catalog entries make current board layouts reviewable without switching Cargo features:

for entry in nobro_hal::BOARD_PACKAGES {
    assert_eq!(entry.package.validate(), Ok(()));
    assert!(entry.report().verify_checksum());
}

Leases

Portable code uses HalLease with neutral LeaseId class/instance values such as LeaseId::PRIMARY_I2C; platform adapters map those IDs to concrete hardware. Board-specific drivers may use ResourceLease and LeaseGuard directly for exclusive ownership of shared peripherals. A driver should acquire a lease, perform bounded work, and let the guard release the resource. Recovery supervisors can inspect the current owner and release every lease held by a faulted owner without disturbing other modules.

Guards are generation-tagged and non-clonable. Safe I2C, SPI, radio, PWM, and nRF scheduling-session operations revalidate the exact acquisition before touching hardware; owner-scoped recovery invalidates stale sessions, quiesces the peripheral, clears nRF interrupt/DMA routing state, and only then publishes the slot as free. Raw low-level register APIs are unsafe and exist for gated compatibility integrations. On nRF52840, Twim0 and Spim0 are programming modes of the same physical peripheral block. Their logical lease IDs therefore exclude each other even when different modules or owner IDs request them.

let guard = nobro_hal::ResourceLease::acquire_guard(nobro_hal::Resource::Twim0, module_id)?;
assert_eq!(nobro_hal::ResourceLease::owner(nobro_hal::Resource::Twim0), Some(module_id));
drop(guard);
nobro_hal::ResourceLease::release_all_for_owner(module_id);

Portable Bus Transactions

HalI2c exposes write, read, and repeated-start write/read operations; HalSpi exposes bounded full-duplex transfers. Each provider declares TRANSFER_MODE, so scheduling and diagnostic code can distinguish polling from DMA instead of assuming one platform-wide behavior. The current deep backend reports polling I2C and DMA SPI.

With the opt-in nrf-twim-async feature, the nRF52840 TwimBus also provides explicit write_async, read_async, and write_read_async EasyDMA futures for reactor code. Synchronous-only firmware does not link this provider. The futures use bounded 64-byte RAM staging, an admitted completion-interrupt priority, and a cancellation barrier that stops DMA before borrowed buffers can leave scope. Bare-metal images enable platform-nrf52840-rt alongside it so the shared SPIM0/TWIM0 vector is present. Invalid addresses, empty operations, over-capacity operations, bus NACKs, and timeouts remain distinct BusError outcomes. Call init_pins before starting either the synchronous or asynchronous path. The portable synchronous HalI2c::TRANSFER_MODE remains honestly reported as Polling; choosing an async method opts into the separate DMA surface.

Owned-peripheral ports use HalAlarm, HalPwmChannel, and HalByteIo for a one-shot deadline, a constructed PWM channel, and bounded USB/serial byte I/O. This avoids global singleton APIs on MCUs whose HAL carries ownership and pin lifetimes in the provider type.

Event Capture

HalEventCapture is the portable abstraction for event-to-timestamp routing. The nRF52840 backend maps it to PPI. Future ports can map it to another trigger fabric without changing app code.

With Cargo feature dma-completion, the RP2350 port provides an experimental Dma0Completion component backed by DMA channel 0 and DMA_IRQ_0. Its equal-length word-copy future registers the waker before starting the channel, disables the channel interrupt before cancellation, and waits for hardware abort before releasing its fixed static staging. This is a port-local implementation of the same completion discipline rather than an nRF-shaped PPI emulation. The feature-off port does not link its future, staging, completion cell, or owned IRQ handler.

The feature-on status image runs a bounded boot self-test. It first cancels a transfer and checks that staged output was not published, then uses DMA timer 0 to pace a test-only copy while the core waits in System-ON WFE. The status fields dma_idle, dma_res_us, dma_total_us, and dma_wake_us respectively report idle entries, measured WFE residence, completion time, and IRQ-to-ready-task latency. The ordinary copy API remains unpaced. These fields are functional and residence evidence on the observed image; they are not electrical-current, energy, or universal power evidence.

Portable staged providers use StagedTransferPlan to reject empty, length-mismatched, and over-capacity operations before claiming hardware.

With the RA4M1 port's opt-in event-dma feature, a periodic GPT0 overflow is routed through ICU/ELC to DMAC0. Only the DMAC0 transfer-end event enters the registered completion future; the CPU does not issue individual transfers. The default surface is one call:

let mut dma = Ra4m1EventDma::take()?;
let copied = dma.copy(&source, &mut destination).await?;

The operation accepts 1 to 64 equal-length words and defaults to one word every 100 microseconds. copy_every exposes a typed 10-to-200-microsecond period override. Invalid lengths or periods are rejected before hardware is claimed, and a globally masked interrupt state returns InterruptsMasked rather than entering sleep. Caller output is published only after completion; dropping the future stops the timers and DMA first.

While active, this provider exclusively owns GPT0, GPT1, DMAC0, DELSR0, and ICU/NVIC slots 30 and 31. Occupied resources are rejected, and their prior register, priority, enable, sleep-event, and module-stop state is restored after completion, error, or cancellation, including pre-existing NVIC pending state. The separate event-dma-selftest feature adds the boot diagnostic to the RA4M1 status image; it is not a baseline cost. Its event_res_us field is System-ON WFE residence, not electrical-current or energy evidence.

SAL API

BusSal

Use for I2C, SPI, and UART-like transactions that need lease-aware access.

trait BusSal {
    type Error;
    fn write_read(&mut self, addr: u8, tx: &[u8], rx: &mut [u8])
        -> Result<(), Self::Error>;
}

SensorSal

Use for sampled data. Payload bytes travel through Sample tickets and static pools rather than heap buffers.

if let Some(sample) = sensor.poll()? {
    runtime.publish_sample(sample)?;
}

nobro-adapter-sensor-stub provides a software fixture for adapter and recovery tests. The default mode emits a plausible IMU sample every 50 polls; custom profiles can model silent sensors, periodic adapter errors, or implausible payloads:

let mut sensor = nobro_adapter_sensor_stub::SensorStub::with_profile(
    2,
    nobro_adapter_sensor_stub::SensorStubProfile::new(
        1,
        nobro_adapter_sensor_stub::SensorStubMode::BadDataEvery(4),
    ),
);
let sample = sensor.poll_at(1_000)?;

ActuatorSal

Use for deadline-aware output.

actuator.set_duty_us(channel, 1500, deadline_us)?;

StreamSal, RadioSal, CryptoSal

StreamSal handles framed byte streams, RadioSal handles radio process loops and packet movement, and CryptoSal keeps cryptographic services behind a portable capability surface.

AiInferenceSal

Use AiInferenceSal for bounded local, sidecar, hybrid, or remote inference. The contract declares backend kind, model identity, max input/output sizes, arena size, and timeout. Callers provide the input and output buffers so an AI adapter does not hide heap allocation behind a model call.

let contract = ai.contract();
assert!(contract.input_bytes_max <= 512);

let input = [0u8; 16];
let mut output = [0u8; 32];
let result = ai.infer(
    nobro_sal::AiInferenceRequest::new(contract.model_id, &input, deadline_us),
    &mut output,
)?;
assert!(usize::from(result.output_len) <= output.len());

Hard-realtime modules should not wait directly on remote inference. They should consume a fresh result snapshot or a degraded fallback state.

Use preflight_ai_invocation before calling an adapter. It checks model ID, input size, output capacity, scratch and arena RAM, local arena declarations, route availability, degraded fallback, stale-snapshot policy, and endpoint circuit state without contacting a model or remote endpoint:

let limits = nobro_sal::AiInvocationLimits::new(
    output.len() as u32,
    128,
    8 * 1024,
    25_000,
);
let report = nobro_sal::preflight_ai_invocation(
    contract,
    policy,
    state,
    nobro_sal::AiInferenceRequest::new(contract.model_id, &input, deadline_us),
    limits,
);
assert!(report.passing());

Use AiRoutePolicy when an application can choose among local inference, an edge sidecar, a third-party API, or a hybrid fallback path. The policy is a fixed-size control record: it compares the model timeout with the caller's budget, trips a small endpoint circuit breaker after repeated failures, and returns a route target without allocating memory. The stale snapshot window is contract-aware: a zero policy window inherits the model contract's stale_after_us, while a non-zero policy uses the stricter of the policy and model windows.

let policy = nobro_sal::AiRoutePolicy::new(
    nobro_sal::AiRoutePreference::HybridFallback,
    50_000,
    3,
);
let state = nobro_sal::AiRuntimeState::new(
    true,   // local model is loaded
    true,   // endpoint transport is ready
    12_000, // last good inference age
    0,      // consecutive endpoint failures
);
let decision = policy.decide(contract, state, 20_000);
assert_ne!(decision.target, nobro_sal::AiRouteTarget::Unavailable);

Adapters can export the same model and routing boundary as a host-readable report:

let report = nobro_sal::AiModelContractReport::from_contract_and_policy(
    contract,
    Some(policy),
);
assert!(report.verify_checksum());
assert_eq!(report.route_preference, nobro_sal::AiRoutePreference::HybridFallback as u32);

ROS-Style Bridges

ROS and micro-ROS compatibility should be implemented through adapters and metadata, not as kernel dependencies. Topic-like streams map to bounded queues, service-like calls map to fixed request/response records, action-like work maps to goal/feedback/result records, and parameters map to fixed-capacity configuration.

RosBridgeSal provides the Rust-side bounded bridge surface. Names and message types are represented by stable hashes so the realtime path does not carry dynamic strings. Inputs and outputs remain caller-owned buffers.

let topic = nobro_sal::RosTopicContract::new(0x10, 0x20, 4, 64);
let service = nobro_sal::RosServiceContract::new(0x30, 16, 16, 50_000);
let contract = nobro_sal::RosBridgeContract::from_parts(
    nobro_sal::RosBridgeTransport::Serial,
    0xA11CE,
    &[topic],
    &[service],
    &[],
    &[],
);

assert_eq!(contract.topic_count, 1);
assert!(contract.total_buffer_bytes <= 512);

Bridge adapters can also publish a compact report that summarizes transport, entity counts, total buffer demand, and maximum timeout:

let report = nobro_sal::RosBridgeContractReport::from_contract(contract);
assert!(report.verify_checksum());
assert_eq!(report.transport, nobro_sal::RosBridgeTransport::Serial as u32);

Use ROS bridge preflight helpers before moving payloads through an adapter:

let topic_check = nobro_sal::preflight_ros_topic(topic, 32);
let service_check = nobro_sal::preflight_ros_service(service, 16, 16, 50_000);
assert!(topic_check.passing());
assert!(service_check.passing());

preflight_ros_topic, preflight_ros_service, preflight_ros_action, and preflight_ros_parameter check payload bounds, response capacity, queue depth, and timeout budgets without contacting a ROS agent or transport.

Python bridge descriptors emit the same stable FNV-1a 32-bit hashes alongside readable names, so host-generated metadata can be reviewed by people and still map cleanly to Rust RosBridgeContract fields.

Python tooling also mirrors AiRoutePolicy for host-side simulations and editor workflows. Use it to validate route decisions before the same policy is wired into Rust or C/C++ firmware code. The check-ai-route CLI wraps a sample policy decision in a pass/fail gate for target selection, stale snapshot reuse, degraded fallback, unavailable routes, and endpoint circuit-breaker state without contacting a network service. Use its backend, preference, budget, readiness, stale-age, and failure-count arguments to model on-device, edge-sidecar, remote API, and hybrid inference paths in CI. The check-ai-route-matrix CLI runs a deterministic compatibility matrix for local, remote API, edge sidecar, stale snapshot, degraded fallback, and unavailable route outcomes. The check-ai-preflight-matrix CLI validates inference-call admission for buffer bounds, arena and scratch RAM, declared local arenas, declared AI capabilities, route budget, stale snapshot limits, degraded fallback, unavailable routes, and endpoint circuit state. The check-ros-preflight-matrix CLI validates ROS bridge-call admission for topic payload bounds, service/action response capacity, queue depth, parameter value size, and timeout budget. The check-public-headers CLI validates public C/C++ report structs, helper functions, forwarding headers, and AI/ROS preflight error-bit coverage. The check-python-surface CLI validates top-level Python package re-exports against __all__ and required host APIs without importing the package. The check-cli-command-surface CLI validates command registration and README coverage for release-check and onboarding commands. The check-software-surface CLI composes host contract, SDK/package metadata, public header, Python public surface, CLI command surface, starter template, AI route matrix, AI preflight matrix, ROS preflight matrix, recovery matrix, watchdog matrix, scheduler matrix, event log matrix, quota matrix, degrade matrix, startup matrix, boot summary matrix, bundle matrix, report matrix, and runtime drill validation for pre-package review.

Host API

nobro-host mirrors all report constants:

use nobro_host::{HostReport, RuntimeReport, RUNTIME_REPORT_SYMBOL};

fn inspect(report: &RuntimeReport) {
    assert_eq!(RuntimeReport::SYMBOL, RUNTIME_REPORT_SYMBOL);
    let status = report.status();
    let checksum_ok = report.verify_checksum();
}

Boot diagnostics can be collapsed into a fixed summary:

let summary = reports.summary();
assert_eq!(summary.first_stage_label(), "runtime");
assert_eq!(summary.pass_count, nobro_host::BOOT_REPORT_STAGE_COUNT as u8);

Host tools should prefer labels from nobro-host instead of embedding numeric tables locally.

Host contract (the JSON ABI mirror)

The host contract defines the data that external tools can read from firmware images or runtime memory. The JSON mirror is:

host/nobro-host-contract.json

The Rust mirror is:

core/crates/nobro_host

Stable Labels

Module tag labels include kernel, HAL, bus, radio, sensor, actuator, stream, crypto, AI, and app modules. Capability labels include timebase, deadline timer, event capture, bus, radio, servo PWM, stream, crypto, sample pool, host report, AI inference, and AI endpoint ownership.

Report Symbols

Symbol Meaning
NOBRO_BOARD_PROFILE_REPORT Selected board, memory origin, budgets, and critical pins
NOBRO_BOARD_PACKAGE_REPORT Boot layout, flash/RAM regions, board capacity, critical pins, and package validation result
NOBRO_MANIFEST_REPORT Static module graph validity, capability bits, budget use, and error context
NOBRO_ADAPTER_COMPAT_REPORT Adapter inventory compatibility before app admission
NOBRO_AI_MODEL_REPORT AI backend, model ID, input/output bounds, arena bytes, timeout, and route policy
NOBRO_ROS_BRIDGE_REPORT ROS-style bridge transport, entity counts, buffer demand, and maximum timeout
NOBRO_ADMISSION_REPORT Admission result after manifest, startup, quota, and capability checks
NOBRO_RUNTIME_REPORT Runtime lifecycle, mailbox pressure, alarms, KV writes, quota use, and event pressure
NOBRO_HEALTH_REPORT Module health counters and latest recovery context
NOBRO_EVENT_LOG_REPORT Fixed event-ring summary
NOBRO_MODULE_RUNTIME_REPORT Module state counts and latest state transition
NOBRO_DEGRADE_APPLICATION_REPORT Latest degraded-mode application result

Status Model

Reports use the same status categories:

  • missing: zeroed report slot
  • in_progress: valid header, incomplete report
  • pass: complete and checksum-valid success
  • fail: complete and checksum-valid domain failure
  • corrupt: invalid header, version, or checksum

Host tools should decode the first non-passing boot stage in this order:

  1. board profile
  2. board package
  3. manifest
  4. adapter compatibility
  5. admission
  6. runtime

Boot Summary

nobro-host exposes BootReports::summary() for tools that need one compact view of boot state. The summary includes the first diagnostic, all six report slots, diagnostic code, and per-status counts. Tools should use this helper before rendering user-facing text.

Python host tooling mirrors this shape in BootReportSummary.to_dict() and the summarize-boot CLI command, including the same diagnostic code layout and per-status count fields. Use check-boot-summary-matrix to validate all-pass, missing-stage, corrupt-checksum, failed-adapter, in-progress-stage, diagnostic-code, and status-count paths before changing report layouts or host tooling.

Checksum Rule

Fixed reports use XOR checksums over every u32 field except checksum. Timestamps wider than u32 are split into low and high words.

Diagnostic Code

Boot diagnostic code layout:

stage_code << 24 | status_class << 16 | error_code_low16

AI And ROS Tables

ai_contracts defines stable numeric codes for AI backend kinds, route preferences, route targets, and the NOBRO_AI_MODEL_REPORT layout. ros_bridge_contracts defines the stable FNV-1a UTF-8 hash policy, ROS-style entity kinds, transport codes, and the NOBRO_ROS_BRIDGE_REPORT layout used by Python, C/C++, and Rust bridge metadata.

Use nobro-host helper labels rather than duplicating numeric maps in host tools.