This document describes the architecture of the Rust reimplementation of halpid, the HALPI2 power monitoring and watchdog daemon. The system follows a modular, layered architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────────┐
│ CLI (halpi) │
│ • User commands │
│ • Pretty output formatting │
└──────────────────────┬──────────────────────────────────┘
│ HTTP over Unix Socket
┌──────────────────────┴──────────────────────────────────┐
│ Daemon (halpid) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ HTTP API Server (axum) │ │
│ │ • REST endpoints │ │
│ │ • Request validation │ │
│ └─────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌─────────────┴──────────────────────────────────────┐ │
│ │ State Machine │ │
│ │ • Power monitoring │ │
│ │ • Blackout detection │ │
│ │ • Shutdown orchestration │ │
│ └─────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌─────────────┴──────────────────────────────────────┐ │
│ │ I2C Communication Layer │ │
│ │ • Register read/write │ │
│ │ • DFU protocol │ │
│ │ • Error handling │ │
│ └─────────────┬──────────────────────────────────────┘ │
└────────────────┼────────────────────────────────────────┘
│ I2C Bus 1, Address 0x6D
┌────────────────┴────────────────────────────────────────┐
│ RP2040 Firmware (HALPI2-firmware) │
│ • Power management state machine │
│ • GPIO control (power rails, USB, LEDs) │
│ • Analog monitoring (voltages, current, temperature) │
└─────────────────────────────────────────────────────────┘
Module: src/i2c/
Purpose: Low-level communication with RP2040 firmware over I2C
Components:
device.rs- MainHalpiDevicestruct, high-level operationsregisters.rs- Register address constants and data typesprotocol.rs- Read/write primitives, encoding/decodingdfu.rs- Firmware update protocol implementationerror.rs- I2C-specific error types
Key Types:
- HalpiDevice - Main device interface containing the Linux I2C device handle, bus number, device address, and cached firmware version for optimization
- Register - Enumeration of all I2C register addresses (0x03 through 0x45) for type-safe register access
- Measurements - Structure holding all sensor readings (input voltage, supercap voltage, input current, MCU temperature, PCB temperature) and current power state
Responsibilities:
- Open I2C device (
/dev/i2c-{bus}) - Atomic register read/write operations
- Big-endian multi-byte value encoding/decoding
- Analog value scaling (16-bit → float)
- Firmware version detection (affects read methods)
- DFU block upload with CRC32 validation
- Error handling and retry logic
Module: src/types/
Purpose: Core domain types used throughout the system
Components:
state.rs- Power management states enumconfig.rs- Configuration structuresmeasurements.rs- Measurement data structuresversion.rs- Version parsing and comparison
Key Types:
- PowerState - Enumeration of all 14 firmware power states (PowerOff through Standby), serializable to JSON for API responses
- Config - Configuration structure with fields for I2C bus/address, blackout timing and voltage thresholds, Unix socket path and permissions, and poweroff command
- Version - Semantic version structure with major, minor, patch numbers and optional alpha designation (255 indicates release version)
Module: src/server/
Purpose: REST API over Unix domain socket for IPC
Components:
app.rs- Axum application setup and routinghandlers/- Endpoint handler functionshealth.rs-/and/versionshutdown.rs-/shutdown,/standbyconfig.rs-/configand/config/{key}values.rs-/valuesand/values/{key}usb.rs-/usband/usb/{port}flash.rs-/flash(firmware upload)
state.rs- Shared application state (Arc<AppState>)error.rs- HTTP error responses
Key Types:
- AppState - Shared application state containing thread-safe references to the I2C device (mutex-protected), configuration (read-write lock), and daemon version string
- Handler functions - Async functions that receive app state and return JSON responses or appropriate HTTP status codes with error messages
Routing:
GET /- Health check endpointGET /version- Daemon version informationPOST /shutdown- Initiate system shutdownPOST /standby- Enter standby mode with RTC wakeupGET /config- Retrieve all configuration valuesGET /config/{key}- Retrieve specific configuration valuePUT /config/{key}- Update configuration valueGET /values- Retrieve all measurements and statusGET /values/{key}- Retrieve specific measurementGET /usb- Get all USB port statesGET /usb/{port}- Get specific USB port statePUT /usb- Set multiple USB port statesPUT /usb/{port}- Set specific USB port statePOST /flash- Upload firmware (multipart form data)
Responsibilities:
- Bind to Unix domain socket
- Set socket permissions (0660) and group ownership
- Route HTTP requests to handlers
- Serialize/deserialize JSON
- Coordinate access to shared
HalpiDevice(viaArc<Mutex<>>) - Return appropriate HTTP status codes and error messages
Module: src/state_machine/
Purpose: Monitor power state and orchestrate graceful shutdown
Components:
machine.rs- Main state machine implementationstates.rs- State definitions and transitionsactions.rs- State entry/exit actions
Key Types:
- DaemonState - Internal state enumeration with five states: Start (initialization), Ok (normal operation), Blackout (power loss detected), Shutdown (shutting down), Dead (waiting for power loss)
- StateMachine - Holds current state, thread-safe references to I2C device and configuration, and optional blackout start timestamp for duration tracking
State Transitions:
START
│ entry: initialize watchdog (10s timeout)
↓
OK
│ loop: monitor V_in
│ if V_in < threshold → BLACKOUT
↓
BLACKOUT
│ entry: record blackout start time
│ loop: check V_in and elapsed time
│ if V_in > threshold → OK
│ if elapsed > time_limit → SHUTDOWN
↓
SHUTDOWN
│ entry: call I2C shutdown (0x30)
│ entry: execute poweroff command
↓
DEAD
│ loop: wait for power loss
Main Loop:
The state machine runs an infinite async loop that dispatches to appropriate handler functions based on current state, then sleeps for one second between iterations. Each handler performs state-specific logic and may transition to a new state.
Responsibilities:
- Initialize watchdog on daemon startup
- Poll voltage measurements every second
- Detect blackout conditions
- Track blackout duration
- Trigger shutdown sequence when threshold exceeded
- Execute system poweroff command
Module: src/cli/
Purpose: User-facing command-line tool
Components:
main.rs- CLI entry point, argument parsingcommands/- Command implementationsstatus.rs- Display system statusconfig.rs- Configuration managementshutdown.rs- Shutdown and standbyusb.rs- USB port controlflash.rs- Firmware upload
client.rs- HTTP client for Unix socket communicationoutput.rs- Formatted output (tables, colors)
Argument Parsing:
The CLI uses derive macros for argument parsing with a main structure that accepts an optional socket path and a required subcommand. Supported commands include status, version, get (retrieve specific value), config (with subcommands), shutdown (with standby options), usb (port control), and flash (firmware upload).
HTTP Client:
A client structure wraps the HTTP client and socket path, providing async methods for each API endpoint including retrieving values, setting configuration, controlling USB ports, and uploading firmware. All methods return typed results with appropriate error handling.
Responsibilities:
- Parse command-line arguments
- Connect to daemon via Unix socket
- Send HTTP requests and handle responses
- Format output for human readability
- Handle errors gracefully with clear messages
- Return appropriate exit codes
Module: src/daemon/
Purpose: Main daemon process coordination
Components:
main.rs- Daemon entry pointrunner.rs- Concurrent task orchestrationsignals.rs- Signal handler (SIGINT, SIGTERM)shutdown.rs- Graceful shutdown coordination
Main Function Flow:
The daemon's main async function orchestrates startup and runtime in seven phases:
- Load configuration from file and CLI arguments
- Initialize structured logging subsystem
- Open I2C device and wrap in thread-safe mutex
- Create shared application state with device, config, and version
- Spawn three concurrent async tasks (HTTP server, state machine, signal handler)
- Wait for any task to complete using async select (typically signal handler)
- Execute graceful shutdown cleanup routine
Concurrent Tasks:
- HTTP Server: Axum server listening on Unix socket
- State Machine: 1-second polling loop for power monitoring
- Signal Handler: Listens for SIGINT/SIGTERM
Graceful Shutdown:
- Stop accepting new HTTP requests
- Cancel state machine loop
- Disable watchdog (I2C command)
- Remove Unix socket file
- Flush logs and exit
Responsibilities:
- Initialize all components
- Coordinate concurrent async tasks
- Handle shutdown signals
- Clean up resources on exit
- Ensure watchdog is disabled before exit
Module: src/config/
Purpose: Load and manage daemon configuration
Components:
loader.rs- Load from YAML file and CLI argsvalidation.rs- Validate configuration valuesdefaults.rs- Default configuration values
Loading Precedence:
Configuration is loaded in four stages: start with built-in defaults, merge values from YAML file if it exists, override with any CLI arguments provided, then validate the final configuration for correctness (ranges, required values, path existence).
YAML Parsing:
- Use
serde_yamlfor deserialization - Convert dashes to underscores in keys
- Handle missing fields with defaults
- Validate types and ranges
Responsibilities:
- Parse YAML configuration file
- Merge configuration sources by precedence
- Validate configuration values
- Provide default values
- Normalize key names (dash → underscore)
| Crate | Purpose | Rationale |
|---|---|---|
| tokio | Async runtime | Industry standard, excellent ecosystem, mature |
| axum | HTTP framework | Modern, ergonomic, built on hyper/tower, great with tokio |
| serde + serde_json + serde_yaml | Serialization | De facto standard for Rust serialization |
| clap | CLI parsing | Most popular, derive macros are ergonomic |
| i2cdev or linux-embedded-hal | I2C access | Linux I2C device interface |
| anyhow | Error handling | Simple error propagation for application code |
| tracing + tracing-subscriber | Logging | Structured logging, async-aware, great ecosystem |
| Crate | Purpose |
|---|---|
| reqwest | HTTP client for CLI |
| tokio-util | Unix socket utilities |
| tower | Middleware (via axum) |
| hyper | HTTP (via axum) |
| crc32fast | CRC32 for DFU protocol |
| once_cell | Lazy static values |
| chrono or time | Date/time parsing for standby mode (basic ISO 8601 support sufficient) |
| humantime | Duration parsing for standby mode (e.g., "1h", "30m") |
Note on standby time parsing: Full compatibility with Python's dateparser library is not required. The Rust implementation should support common formats like integer seconds, ISO 8601 datetime strings, and simple duration expressions. Implementation can use standard Rust datetime parsing and humantime crate rather than attempting to replicate Python's flexible parsing.
| Tool | Purpose |
|---|---|
| cargo | Build system |
| cargo-deb | Debian package generation |
| cross | Cross-compilation tool for building on x86_64 or ARM64 hosts |
| rustfmt | Code formatting |
| clippy | Linting |
| cargo-nextest | Test runner |
| cargo-watch | Development file watching |
Target: aarch64-unknown-linux-musl (static binary)
Rationale for static linking with musl:
- Universal compatibility - Single binary works on any ARM64 Linux distribution (Raspberry Pi OS, Victron OS, Alpine, Ubuntu, Debian variants, etc.)
- No runtime dependencies - Eliminates glibc version conflicts and dependency issues
- Lower memory usage - musl's simpler allocator typically uses 1-3 MB less RAM than glibc (~7-10 MB RSS vs 10-15 MB)
- Simplified distribution - Single binary for all Linux variants, ideal for GitHub releases
- Predictable behavior - No system library version surprises across different OS installations
- Binary size trade-off acceptable - ~1-2 MB larger binary is negligible on modern systems with multi-GB storage
Cross-Compilation Setup:
Development typically happens on x86_64 or ARM64 hosts, cross-compiling to ARM64 Linux target.
Option 1: Using cross (recommended for simplicity):
Install the cross tool via cargo, then build using the cross command targeting aarch64-unknown-linux-musl. No additional toolchain setup needed as cross handles everything in Docker containers.
Option 2: Native toolchain (for faster builds):
Install the musl target via rustup, then install a platform-specific linker (musl-tools on Ubuntu/Debian, filosottile musl-cross on macOS, or zig as a cross-platform alternative). Configure the linker in .cargo/config.toml, then build normally with cargo targeting aarch64-unknown-linux-musl.
Development Workflow:
- Build on development machine (x86_64 or ARM64)
- Transfer binary to Raspberry Pi using scp to a test location
- Test on actual HALPI2 hardware (I2C requires real hardware)
- Iterate based on test results
Build Process:
- Development builds: Use cross or cargo with the musl target
- Release builds: Add --release flag for optimizations
- CPU-specific optimizations: Set RUSTFLAGS with target-cpu=cortex-a72 for Raspberry Pi CM5
No dynamic builds needed - Static musl binary covers all deployment scenarios, including:
- Standard Raspberry Pi OS installations
- Alternative distributions (Victron OS, etc.)
- Container environments
- Minimal/embedded Linux systems
User runs: halpi status
↓
CLI parses arguments (clap)
↓
CLI creates HTTP client (reqwest)
↓
CLI sends GET /values to Unix socket
↓
Daemon's axum server receives request
↓
Handler acquires lock on HalpiDevice
↓
Handler reads I2C registers
↓
Handler releases lock
↓
Handler returns JSON response
↓
CLI receives JSON
↓
CLI formats output (pretty table)
↓
User sees formatted status
State machine polls V_in every 1 second
↓
V_in < 9.0V (blackout detected)
↓
State: OK → BLACKOUT
Record blackout_start = Instant::now()
↓
Continue polling...
↓
Elapsed time > 5.0 seconds
↓
State: BLACKOUT → SHUTDOWN
↓
Acquire lock on HalpiDevice
↓
Write 0x01 to register 0x30 (I2C shutdown request)
↓
Release lock
↓
Execute command: /sbin/poweroff
↓
State: SHUTDOWN → DEAD
↓
Wait for system to power down
User runs: halpi flash firmware.bin
↓
CLI reads firmware file into memory
↓
CLI sends POST /flash with multipart form data
↓
Daemon handler receives file
↓
Handler validates file size and format
↓
Handler acquires lock on HalpiDevice
↓
Handler calls device.start_dfu(total_size)
↓
For each 4KB block:
- Calculate CRC32
- Upload block via I2C register 0x43
- Poll DFU status (register 0x41)
- Wait if QUEUE_FULL, retry
- Abort if error state
↓
Handler calls device.commit_dfu()
↓
Handler releases lock
↓
Handler returns 204 No Content (success)
↓
CLI displays success message
Static binary distribution - Single self-contained binary works across all ARM64 Linux distributions:
- Raspberry Pi OS (Bookworm, Bullseye)
- Victron OS (Venus OS)
- Alpine Linux
- Ubuntu/Debian variants
- Any ARM64 Linux with kernel 4.4+
Distribution channels:
- Debian package (
.deb) - Recommended for Raspberry Pi OS and Debian-based systems - Raw binary - GitHub releases for alternative distributions (Victron OS, etc.)
- Container image - Future enhancement for containerized deployments
┌─────────────────────────────────────────────────────────┐
│ Systemd │
│ ┌────────────────────────────────────────────────────┐ │
│ │ halpid.service │ │
│ │ Type=simple │ │
│ │ ExecStart=/usr/bin/halpid │ │
│ │ Restart=on-failure │ │
│ │ RestartSec=10 │ │
│ │ User=root │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────┐
│ File System │
│ /usr/bin/halpid (static binary - daemon) │
│ /usr/bin/halpi (static binary - CLI) │
│ /etc/halpid/halpid.conf (YAML config) │
│ /run/halpid.sock (Unix socket, 0660, group=adm)│
│ /lib/systemd/system/halpid.service (systemd unit) │
└─────────────────────────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────┐
│ Kernel │
│ /dev/i2c-1 (I2C device node) │
│ /dev/rtc0 (RTC for standby mode) │
└─────────────────────────────────────────────────────────┘
halpid_5.0.0_arm64.deb
├── usr/bin/halpid (daemon binary)
├── usr/bin/halpi (CLI binary)
├── etc/halpid/halpid.conf (default config)
├── lib/systemd/system/halpid.service (systemd unit)
└── DEBIAN/
├── control (package metadata)
├── postinst (post-install script)
└── prerm (pre-removal script)
Package Metadata:
Package: halpid
Version: 5.0.0
Architecture: arm64
Conflicts: halpid (<< 5.0.0)
Description: HALPI2 power monitoring and watchdog daemon (static binary)
Note: No libc6 dependency - binaries are statically linked with musl libc and have no runtime library dependencies.
Post-Install Script:
- Enable systemd service
- Start daemon
- Create
admgroup if needed
Pre-Removal Script:
- Stop daemon
- Disable systemd service
Daemon (halpid):
- Must run as root:
- I2C device access (
/dev/i2c-1requires root ori2cgroup) - System shutdown (
/sbin/poweroffrequires root) - Unix socket in
/var/run(requires root)
- I2C device access (
CLI (halpi):
- Can run as any user in socket group (default:
adm) - Socket permissions: 0660 (owner + group read/write)
External Inputs:
- CLI arguments - Validated by clap, type-safe
- HTTP requests - Validated by axum handlers, JSON schema
- Configuration file - Parsed by serde_yaml, validated after load
- I2C responses - Binary data, validate lengths and ranges
Mitigations:
- Input validation at boundaries
- Type safety (Rust)
- Bounds checking on array access
- CRC validation for firmware uploads
- No arbitrary command execution (poweroff command is configurable but validated)
- Regular
cargo auditruns - Pin dependencies in
Cargo.lock - Minimal dependency tree
- Prefer well-maintained, popular crates
HALPI2-rust-daemon/
├── Cargo.toml # Workspace root
├── Cargo.lock # Dependency lock file
├── README.md # Project overview
├── LICENSE # License file
├── CLAUDE.md # Claude Code instructions
├── .gitignore
├── .cargo/
│ └── config.toml # Cross-compilation configuration
├── .github/
│ └── workflows/
│ ├── ci.yml # Build and test
│ └── release.yml # Debian package build
│
├── docs/
│ ├── SPEC.md # Technical specification
│ ├── ARCHITECTURE.md # This file
│ └── MIGRATION.md # Python → Rust migration guide
│
├── halpid/ # Daemon binary crate
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # Daemon entry point
│ ├── config/ # Configuration management
│ │ ├── mod.rs
│ │ ├── loader.rs
│ │ ├── validation.rs
│ │ └── defaults.rs
│ ├── daemon/ # Daemon orchestration
│ │ ├── mod.rs
│ │ ├── runner.rs
│ │ ├── signals.rs
│ │ └── shutdown.rs
│ ├── i2c/ # I2C communication layer
│ │ ├── mod.rs
│ │ ├── device.rs
│ │ ├── registers.rs
│ │ ├── protocol.rs
│ │ ├── dfu.rs
│ │ └── error.rs
│ ├── server/ # HTTP API server
│ │ ├── mod.rs
│ │ ├── app.rs
│ │ ├── state.rs
│ │ ├── error.rs
│ │ └── handlers/
│ │ ├── mod.rs
│ │ ├── health.rs
│ │ ├── shutdown.rs
│ │ ├── config.rs
│ │ ├── values.rs
│ │ ├── usb.rs
│ │ └── flash.rs
│ ├── state_machine/ # Power management state machine
│ │ ├── mod.rs
│ │ ├── machine.rs
│ │ ├── states.rs
│ │ └── actions.rs
│ └── types/ # Shared data types
│ ├── mod.rs
│ ├── state.rs
│ ├── config.rs
│ ├── measurements.rs
│ └── version.rs
│
├── halpi/ # CLI binary crate
│ ├── Cargo.toml
│ └── src/
│ ├── main.rs # CLI entry point
│ ├── client.rs # HTTP client
│ ├── output.rs # Formatted output
│ └── commands/ # Command implementations
│ ├── mod.rs
│ ├── status.rs
│ ├── config.rs
│ ├── shutdown.rs
│ ├── usb.rs
│ └── flash.rs
│
├── halpi-common/ # Shared library crate
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── types.rs # Common types
│ ├── protocol.rs # I2C protocol constants
│ └── error.rs # Common error types
│
├── debian/ # Debian packaging
│ ├── control # Package metadata
│ ├── changelog # Debian changelog
│ ├── rules # Build rules
│ ├── halpid.install # File installation map
│ ├── halpid.service # Systemd unit file
│ ├── halpid.postinst # Post-install script
│ ├── halpid.prerm # Pre-removal script
│ └── copyright # Copyright information
│
├── config/
│ └── halpid.conf # Default YAML config
│
└── tests/ # Integration tests
├── integration_test.rs # Full system tests
├── api_test.rs # HTTP API tests
└── fixtures/
└── test_firmware.bin # Test firmware file
The error handling uses a hierarchical approach with specialized error types:
- AppError - Top-level application errors with automatic conversion from I2C, configuration, and server errors, plus a device-not-found variant and catch-all for other errors
- I2cError - I2C-specific errors including device errors (from underlying I2C library), register read/write failures, and DFU operation errors
- ServerError - HTTP-specific errors mapped to status codes: BadRequest (400), NotFound (404), and Internal (500)
I2C Layer:
Implements retry logic for transient errors. Register read operations attempt up to 3 times with 10ms delays between attempts for transient failures. Persistent errors are propagated immediately.
HTTP Handlers:
Server errors implement automatic conversion to HTTP responses with appropriate status codes and JSON error messages. Each error variant maps to a specific HTTP status code with the error message in the response body.
CLI:
The main function catches errors from the run function, prints user-friendly error messages to stderr, and exits with code 1. This provides clear feedback without exposing technical implementation details.
- I2C layer: Mock I2C device, test encoding/decoding
- Data types: Test serialization, validation
- Configuration: Test loading, merging, validation
- State machine: Test transitions, timing
- HTTP API: Test all endpoints with test server
- CLI: Test commands with mock daemon
- DFU: Test firmware upload with simulated device
- On actual HALPI2 hardware:
- I2C communication
- Analog readings
- Watchdog feeding
- State machine transitions
- Firmware upload
- GitHub Actions workflow
- Build on ARM64 (cross-compile or native)
- Run unit and integration tests
- Build Debian package
- Run
cargo clippyandcargo fmt --check - Run
cargo audit
Target: <10MB RSS
Strategies:
- Use
Arcfor shared data (avoid cloning) - Stream firmware uploads (don't load entire file)
- Limit log buffer size
- Use compact data structures
Target: <1% CPU during idle
Strategies:
- Event-driven HTTP server (tokio)
- Sleep between state machine polls (1 second)
- Efficient I2C operations (atomic, no polling)
Targets:
- I2C read/write: <1ms
- HTTP API response: <5ms
- CLI command: <50ms total
Strategies:
- Use async I/O (tokio)
- Minimize lock contention (short critical sections)
- Optimize hot paths (state machine loop)
Not in scope for v5.0.0, but documented for future reference:
- Prometheus metrics endpoint
- Grafana dashboard
- Alerting on blackout events
- Configuration hot-reload (SIGHUP)
- Multiple concurrent firmware uploads
- Prometheus metrics export
- JSON output mode for CLI (scripting)
- systemd socket activation
- Zero-copy I2C operations
- Lock-free shared state (if measurable contention)
- Profile-guided optimization (PGO)
This architecture provides:
✅ Modularity - Clear separation of concerns ✅ Testability - Mockable interfaces, isolated components ✅ Performance - Async I/O, efficient resource usage ✅ Reliability - Strong typing, error handling, graceful degradation ✅ Maintainability - Idiomatic Rust, well-documented ✅ Compatibility - Identical external interfaces to Python version
The implementation follows Rust best practices while preserving the proven design of the Python version.