diff --git a/README.md b/README.md index 71bec9afb..26583ace2 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,9 @@ scripts/start-kiosk.sh --swing-speed # Development mode (no hardware) scripts/start-kiosk.sh --mock + +# With Geekworm X1202/X1206 battery monitoring +scripts/start-kiosk.sh --battery geekworm ``` The IWR6843 example values are not universal. Measure the geometry from the @@ -303,6 +306,8 @@ uv run pytest tests/ -v - **[Parts List](docs/PARTS.md)** — What to buy - **[Sound Trigger Wiring](docs/sound-trigger-wiring.md)** — How to wire the sound trigger - **[Raspberry Pi Setup](docs/raspberry-pi-setup.md)** — Full setup guide +- **[Battery Monitoring](docs/battery/README.md)** — Provider architecture, UI states, and shared Pi support +- **[Geekworm X1202/X1206 Operator Guide](docs/battery/geekworm.md)** — Batteries, Pi setup, native telemetry, and warnings - **[IWR6843 Operator Guide](docs/iwr6843/README.md)** — Wire, flash, mount, aim, and calibrate the angle radar - **[LIS3DH Inclinometer Setup](docs/inclinometer/README.md)**: Add enclosure-level compensation to IWR6843 tilt - **[OPS243 USB → GPIO UART Migration](docs/ops243-uart-migration.md)** — Required before adding the IWR6843 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c94357c01..324cbd7d2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Battery and external-power status for Raspberry Pi UPS boards.** OpenFlight + can now display charging state and battery percentage, issue dismissible 20% + and 10% warnings while discharging, and record throttled power telemetry in + session logs. Enable the initial Geekworm X1202/X1206 provider with + `--battery geekworm`; monitoring remains disabled when no provider is + selected. The accompanying Pi setup installs native Linux power-supply + telemetry and optional taskbar capacity support without enabling automatic + shutdown or charging control. See [Battery Monitoring](battery/README.md). - **System Prerequisites:** Documented missing binary dependencies (`swig`, `liblgpio-dev`, `python3-dev`) required prior to executing `./scripts/setup/setup.sh`. - **Environment Reload Guidance:** Added instructions for reloading terminal environment variables (`source ~/.bashrc`) when installed dependencies or scripts (`setup.sh`, `start-kiosk.sh`) are not recognized in the current terminal session. - **Configurable IWR6843 capture compression.** One firmware image can now diff --git a/docs/PARTS.md b/docs/PARTS.md index 46f64218b..8163c7685 100644 --- a/docs/PARTS.md +++ b/docs/PARTS.md @@ -139,7 +139,8 @@ One unit is mounted vertically (launch angle), one horizontally (club path / aim | Part | Description | Link | ~Price | |------|-------------|------|--------| | Tripod Mount | For positioning the unit | 1/4"-20 mount | $10 | -| **Geekworm X1202 UPS HAT** | Rechargeable Pi 5 power using four compatible 18650 Li-ion cells. Cells are not included; follow the manufacturer's cell requirements | [Amazon](https://www.amazon.com/dp/B0CRZ4ZXQW?ref=ppx_yo2ov_dt_b_fed_asin_title) | ~$48 + ~$25 for cells | +| **Geekworm X1202 UPS HAT** | Rechargeable Pi 5 power using four matching flat-top 18650 Li-ion cells. Cells are not included | [Geekworm](https://geekworm.com/products/x1202) | ~$48 + cells | +| **Geekworm X1206 UPS HAT** | Larger rechargeable Pi 5 power option using four matching 21700 Li-ion cells, advertised up to 20,000mAh total. Cells are not included | [Geekworm](https://geekworm.com/products/x1206) | Varies + cells | | **InnoMaker OV9281 global-shutter camera** | High-speed monochrome camera for experimental vision work. Camera software is not enabled in the production kiosk path | [Amazon](https://www.amazon.com/dp/B09WTP5GZH?th=1) | ~$30 | See [Camera and YOLO Experiments](yolo-performance-tuning.md) before buying the diff --git a/docs/battery/README.md b/docs/battery/README.md new file mode 100644 index 000000000..64f679a75 --- /dev/null +++ b/docs/battery/README.md @@ -0,0 +1,108 @@ +# Battery Monitoring + +OpenFlight can display battery percentage, external-power state, and low-battery +warnings when a supported UPS provider is selected. Monitoring is disabled by +default and is read-only: OpenFlight does not change charging behavior or shut +down Linux automatically. + +## Supported Providers + +| Provider | CLI value | Hardware | Setup guide | +|---|---|---|---| +| Geekworm | `geekworm` | X1202 and X1206 | [Geekworm X1202/X1206](geekworm.md) | + +Start OpenFlight with an installed provider: + +```bash +scripts/start-kiosk.sh --battery geekworm +``` + +The `--battery` argument is intentionally explicit. When it is absent, +OpenFlight does not probe UPS hardware and does not show the battery UI. + +## Implementation + +Battery support is split into generic monitoring and hardware providers: + +```text +CLI provider selection + | + v +PowerReader factory --> Linux power_supply reader + | | + | unavailable | PowerSample + v v +Provider reader ----------> PowerMonitor --> WebSocket UI + `--> session JSONL +``` + +Every provider implements the `PowerReader` contract and returns the common +`PowerSample` fields: + +- Battery percentage +- Battery voltage +- External-power availability + +The factory first uses standard Linux devices under `/sys/class/power_supply` +when the installed kernel driver exposes both a battery and mains supply. It +falls back to the selected provider's direct hardware reader when native Linux +telemetry is unavailable. + +The generic `PowerMonitor` owns polling, retry, state classification, UI +publication, and throttled session logging. Hardware readers do not implement +warnings or shutdown policy. + +## UI And Logging + +The UI reports plugged-in, on-battery, low, critical, and unavailable states. +Warnings at 20% and 10% are dismissible and appear only while discharging. + +![OpenFlight showing a plugged-in battery at 78%](images/openflight-battery-status.jpg) + +### Indicator States + +| Plugged in | On battery | Low | Critical | Unavailable | +|---|---|---|---|---| +| ![Plugged in at 78%](images/battery-icon-plugged-in.jpg) | ![On battery at 64%](images/battery-icon-on-battery.jpg) | ![Low battery at 20%](images/battery-icon-low.jpg) | ![Critical battery at 10%](images/battery-icon-critical.jpg) | ![Battery telemetry unavailable](images/battery-icon-unavailable.jpg) | + +### Warning Dialogs + +| Low battery | Critically low battery | +|---|---| +| ![Dismissible low-battery warning at 20%](images/battery-alert-low.jpg) | ![Dismissible critical-battery warning at 10%](images/battery-alert-critical.jpg) | + +Session logs contain `power_status` records with the provider, percentage, +voltage, external-power state, availability, timestamp, and any read error. +The session-start configuration also records the selected provider. + +## Raspberry Pi Taskbar Compatibility + +Raspberry Pi's `wfplug-batt` panel normally calculates percentage from charge +or energy counters. Some standard Linux battery drivers expose an already +calculated `capacity` value instead. OpenFlight's shared taskbar patch adds +support for that standard property and clamps full-charge overshoot to 100%. + +![Raspberry Pi desktop showing the taskbar battery indicator](images/raspberry-pi-taskbar.png) + +The shared ARM64 Raspberry Pi OS Trixie package and source patch are stored at: + +```text +scripts/battery/packages/ +scripts/battery/patches/ +``` + +This compatibility package is not Geekworm-specific. A provider setup script +may install it when that provider's Linux driver needs capacity-only support. +It affects only the Raspberry Pi desktop panel; OpenFlight's UI reads the same +Linux telemetry directly and does not require the panel package. + +## Adding A Provider + +Add hardware integrations under `src/openflight/power/providers/`, register the +provider name and reader in `src/openflight/power/factory.py`, and add focused +reader and factory tests. Provider-specific Pi provisioning belongs under +`scripts/battery//`, with its operator guide under `docs/battery/`. + +Keep provider readers read-only. Any future controlled shutdown behavior must +remain a separate policy that first stops OpenFlight cleanly, asks Linux to +halt, and only then allows the UPS to remove Pi power. diff --git a/docs/battery/geekworm.md b/docs/battery/geekworm.md new file mode 100644 index 000000000..e9286b92b --- /dev/null +++ b/docs/battery/geekworm.md @@ -0,0 +1,286 @@ +# Geekworm X1202/X1206 Operator Guide + +This guide covers installation, Raspberry Pi configuration, verification, and +OpenFlight battery monitoring for the Geekworm X1202 and X1206 UPS boards. +See the [battery monitoring overview](README.md) for the shared provider +architecture, UI behavior, and session logging contract. +Both boards use the same telemetry interface: + +- A MAX17040/MAX17043-compatible fuel gauge at I2C address `0x36` +- GPIO6 high when external input power is available +- GPIO16 for charging control, which OpenFlight intentionally does not use + +OpenFlight only monitors the UPS. It does not change charging behavior and +does not automatically shut down Linux at low battery levels. + +## Choose A Board + +Use Geekworm's current product documentation to confirm battery and input-power +requirements before buying cells or an adapter: + +- [Geekworm X1202 product page](https://geekworm.com/products/x1202) +- [Geekworm X1202 wiki and battery requirements](https://wiki.geekworm.com/X1202) +- [Geekworm X1206 product page](https://geekworm.com/products/x1206) +- [Geekworm X1206 wiki and revision details](https://wiki.geekworm.com/X1206) + +| | X1202 | X1206 | +|---|---|---| +| Battery format | Four 3.7V 18650 cells | Four 3.7V 21700 cells | +| Battery callout | Flat-top, unprotected cells required by Geekworm | Larger 21700 cells; Geekworm advertises up to 20,000mAh total | +| UPS output | 5.1V, up to 5A | 5.1V, up to 6A | +| Board size | 97.4 x 85mm | 108 x 85mm | +| Best fit | Smaller and lighter | Longer runtime and more output headroom | + +The four LEDs are coarse voltage bands, not four equal fuel-gauge readings. +OpenFlight uses the MAX17040 state-of-charge model instead, so the LEDs and UI +percentage will not always change together. + +> [!WARNING] +> Do not substitute protected cells where Geekworm requires unprotected cells. +> Use four matching, healthy cells of the required size and chemistry, observe +> polarity, and follow Geekworm's handling instructions. Do not mix cell models, +> capacities, ages, or charge states. + +### X1206 Input Revisions + +Check the revision printed on the X1206 board before selecting a DC adapter: + +| Revision | USB-C input | DC5521/XH2.54 input | +|---|---|---| +| X1206 V1.1 | 5V/5A | 5-6V, at least 3A | +| X1206 V2.0 | 5V/5A | 9-18V, at least 3A; Geekworm recommends 12V/5A | + +Do not infer the DC voltage from the product name. Supplying a V1.1 board with +the V2.0 adapter voltage can damage it. + +## Install The Hardware + +1. Shut down the Pi and remove all power. +2. Install all four matching cells with the polarity shown on the UPS holder. +3. Mount the Pi squarely on the UPS and verify that every pogo pin is centered + and compressed against its Pi pad. +4. Connect the external adapter to either the UPS USB-C input or the supported + UPS DC input for that board revision. +5. Connect powered peripherals through the UPS or a separately powered hub. +6. Apply power and start the Pi with the UPS power button. + +> [!WARNING] +> Do not power the Pi through the Pi's own USB-C socket while it is installed on +> the UPS. Connect input power to the Geekworm board. Do not connect the UPS +> USB-C and DC inputs at the same time. + +Poor pogo-pin contact can produce missing I2C data, incorrect charger state, +undervoltage warnings, or a shutdown a few seconds after power-on. Power down +before reseating the boards. + +## Configure Raspberry Pi OS + +Run the checked-in setup script from the OpenFlight repository root: + +```bash +sudo scripts/battery/geekworm/setup.sh +sudo reboot +scripts/battery/geekworm/setup.sh --verify +``` + +The script is safe to rerun. It performs these changes: + +1. Installs `i2c-tools` and `upower` when missing. +2. Loads `i2c-dev` at boot for I2C diagnostics. +3. Enables I2C in `/boot/firmware/config.txt`. +4. Adds the native MAX17040 battery and active-high GPIO6 charger overlays. +5. Sets `PSU_MAX_CURRENT=5000` in the Pi 5 bootloader EEPROM so Raspberry Pi OS + recognizes the UPS as a 5A-capable supply. +6. Sets `POWER_OFF_ON_HALT=1` so the UPS can remove power after Linux halts. +7. On 64-bit Raspberry Pi OS Trixie, installs OpenFlight's patched `wfplug-batt` + package so the desktop panel reads the kernel's percentage. + +Before changing boot or EEPROM configuration, the script writes timestamped +backups. It reports every change and tells you when a reboot is required. + +The script does **not** install the community `x120x-dkms` driver, Geekworm's +automatic shutdown service, or any low-battery poweroff job. OpenFlight's 20% +and 10% warnings remain informational and dismissible. + +### Desktop Panel Compatibility + +The upstream Raspberry Pi battery panel calculates percentage from +`charge_now/charge_full` or `energy_now/energy_full`. The standard MAX17040 +kernel driver exposes an already-calculated `capacity` percentage instead, so +the unpatched panel displays 0% even though UPower and OpenFlight have valid +data. + +The setup script installs the ARM64 package in +`scripts/battery/packages/`. It is built from Raspberry Pi's +[`pplug-batt`](https://github.com/raspberrypi-ui/pplug-batt) source at commit +`f4c18fbca9e1b752e35b6ea8a854676b4777de3b`, with the checked-in patch under +`scripts/battery/patches/`. The patch adds support for the standard +`/sys/class/power_supply/.../capacity` property and clamps full-charge +overshoot to 100%. + +This package affects only Raspberry Pi's desktop taskbar. OpenFlight's own +battery display works without it. Use `--no-panel` if the Pi does not run the +standard 64-bit Raspberry Pi desktop: + +```bash +sudo scripts/battery/geekworm/setup.sh --no-panel +``` + +### Optional Pi USB Current Setting + +Geekworm also recommends `usb_max_current_enable=1` when high-current USB +peripherals are powered directly from the Pi's USB ports. OpenFlight does not +set it automatically because increasing the USB current limit should be a +deliberate power-budget decision. Prefer a separately powered hub for multiple +radars. + +## What The Boot Configuration Contains + +The setup script adds these settings under an `[all]` section when absent: + +```ini +dtparam=i2c_arm=on +dtoverlay=i2c-sensor,max17040 +dtoverlay=gpio-charger,gpio=6,active_low=0,gpio_pull=down,type=mains +``` + +The first overlay binds Linux's `max17040_battery` driver to I2C address +`0x36`. The second binds `gpio_charger` to GPIO6, where low means adapter +failure and high means external power is good. + +After reboot, the expected native devices are: + +```text +/sys/class/power_supply/battery +/sys/class/power_supply/charger +``` + +## Verify The Installation + +Run the automated checks: + +```bash +scripts/battery/geekworm/setup.sh --verify +``` + +The command verifies the EEPROM settings, overlays, kernel modules, battery +percentage and voltage, charger state, UPower data, and desktop panel package. + +Manual checks are also useful: + +```bash +# The bound kernel driver appears as UU at address 36. +sudo i2cdetect -y 1 + +cat /sys/class/power_supply/battery/capacity +cat /sys/class/power_supply/battery/voltage_now +cat /sys/class/power_supply/charger/online +upower -i /org/freedesktop/UPower/devices/battery_battery +vcgencmd get_throttled +``` + +Expected results: + +- I2C address `36` is present before the overlay binds it, or `UU` after it is + owned by the kernel driver. +- `capacity` is an integer near 0-100. Some MAX17040 readings briefly report + 101 or 102 at full charge; OpenFlight and the patched panel display that as + 100%. +- `voltage_now` is in microvolts, typically about 3,400,000-4,230,000. +- `charger/online` changes from `1` to `0` when UPS input power is removed. +- `get_throttled=0x0` means the Pi has no current or historical undervoltage or + throttling flags since boot. + +## Start OpenFlight + +Enable UPS monitoring explicitly: + +```bash +scripts/start-kiosk.sh --battery geekworm +``` + +With the flag enabled, OpenFlight shows: + +- Battery percentage from the MAX17040 fuel gauge +- A charging/plug icon while GPIO6 reports external power +- A normal battery state after the adapter is unplugged +- Dismissible warnings at 20% and 10% while discharging +- A red unavailable state if telemetry cannot be read + +The monitor retries every five seconds. A temporary hardware read failure does +not stop shot capture or shut down the Pi. + +## Session Logs + +Session JSONL files include `power_status` entries on startup, power-state +changes, warning-threshold changes, telemetry failure or recovery, and at least +once per minute while unchanged. Each entry includes: + +- `state` +- `provider` +- `battery_percent` +- `battery_voltage_v` +- `external_power` +- `available` +- `error` +- `updated_at` + +When `--battery geekworm` is absent, OpenFlight does not access the UPS hardware +or show a battery indicator. + +## Troubleshooting + +### OpenFlight Shows A Red `--` + +Check the native files first: + +```bash +ls -l /sys/class/power_supply +cat /sys/class/power_supply/battery/capacity +cat /sys/class/power_supply/charger/online +``` + +If the files are missing, confirm the overlays and reboot. If only the battery +is missing, power down and reseat the pogo pins carrying GPIO2/GPIO3 I2C. If +only charger state is wrong, inspect GPIO6/physical pin 31 contact and confirm +the adapter is connected to the UPS input rather than the Pi USB-C socket. + +### Raspberry Pi's Taskbar Says 0% + +OpenFlight and UPower may still be correct. Check the compatibility package: + +```bash +dpkg-query -W wfplug-batt +dpkg -V wfplug-batt +sudo scripts/battery/geekworm/setup.sh +``` + +An OS update may replace the panel plugin with an upstream version. Rerunning +the setup script restores the patched package when needed. + +### LEDs And Percentage Disagree + +The LEDs are voltage thresholds; OpenFlight displays fuel-gauge SOC. Voltage +changes immediately with charging and load, while modeled SOC can move more +slowly and may need several full charge/discharge cycles to settle. + +### External Power State Is Backwards Or Stuck + +Geekworm documents GPIO6 as active-high: high means power good and low means +power loss. Keep `active_low=0`. Do not invert the software to conceal a low +signal while an adapter is connected; verify the UPS input and pogo contact. + +### Roll Back Pi Changes + +The setup script prints the boot-config and EEPROM backup paths it creates. +Restore the appropriate backup, then reboot. To return to Raspberry Pi's +unpatched repository package, first identify the repository version and then +request that version explicitly: + +```bash +apt-cache policy wfplug-batt +sudo apt install --allow-downgrades wfplug-batt= +``` + +Removing the overlays disables native OS telemetry. OpenFlight can fall back to +direct I2C/GPIO reads, but native devices are the supported Pi configuration. diff --git a/docs/battery/images/battery-alert-critical.jpg b/docs/battery/images/battery-alert-critical.jpg new file mode 100644 index 000000000..532ed1ee9 Binary files /dev/null and b/docs/battery/images/battery-alert-critical.jpg differ diff --git a/docs/battery/images/battery-alert-low.jpg b/docs/battery/images/battery-alert-low.jpg new file mode 100644 index 000000000..8050b6ae5 Binary files /dev/null and b/docs/battery/images/battery-alert-low.jpg differ diff --git a/docs/battery/images/battery-icon-critical.jpg b/docs/battery/images/battery-icon-critical.jpg new file mode 100644 index 000000000..86f5afbe9 Binary files /dev/null and b/docs/battery/images/battery-icon-critical.jpg differ diff --git a/docs/battery/images/battery-icon-low.jpg b/docs/battery/images/battery-icon-low.jpg new file mode 100644 index 000000000..22422f2c5 Binary files /dev/null and b/docs/battery/images/battery-icon-low.jpg differ diff --git a/docs/battery/images/battery-icon-on-battery.jpg b/docs/battery/images/battery-icon-on-battery.jpg new file mode 100644 index 000000000..c2cb61678 Binary files /dev/null and b/docs/battery/images/battery-icon-on-battery.jpg differ diff --git a/docs/battery/images/battery-icon-plugged-in.jpg b/docs/battery/images/battery-icon-plugged-in.jpg new file mode 100644 index 000000000..c679f3ef7 Binary files /dev/null and b/docs/battery/images/battery-icon-plugged-in.jpg differ diff --git a/docs/battery/images/battery-icon-unavailable.jpg b/docs/battery/images/battery-icon-unavailable.jpg new file mode 100644 index 000000000..8d18f8498 Binary files /dev/null and b/docs/battery/images/battery-icon-unavailable.jpg differ diff --git a/docs/battery/images/openflight-battery-status.jpg b/docs/battery/images/openflight-battery-status.jpg new file mode 100644 index 000000000..0b02628d5 Binary files /dev/null and b/docs/battery/images/openflight-battery-status.jpg differ diff --git a/docs/battery/images/raspberry-pi-taskbar.png b/docs/battery/images/raspberry-pi-taskbar.png new file mode 100644 index 000000000..c1bca5b90 Binary files /dev/null and b/docs/battery/images/raspberry-pi-taskbar.png differ diff --git a/docs/raspberry-pi-setup.md b/docs/raspberry-pi-setup.md index 3d103b54d..3aad6c384 100644 --- a/docs/raspberry-pi-setup.md +++ b/docs/raspberry-pi-setup.md @@ -16,7 +16,7 @@ Make sure you have all the hardware. See the **[Parts List](PARTS.md)** for what **Optional:** - TI IWR6843LEVM + data cable — measured launch angle and experimental club path; see the [IWR6843 Operator Guide](iwr6843/README.md) -- Geekworm X1202 UPS HAT (~$48) + four compatible 18650 cells (~$25 total) — portable Pi 5 power; cells are sold separately +- Geekworm X1202 or X1206 UPS HAT — portable Pi 5 power using four separately purchased 18650 or 21700 cells; see the [battery monitoring overview](battery/README.md) and [Geekworm operator guide](battery/geekworm.md) - InnoMaker OV9281 global-shutter camera (~$30) — experimental vision work; see [Camera and YOLO Experiments](yolo-performance-tuning.md) **Optional (deprecated):** @@ -57,9 +57,11 @@ configuration with prompts: (you'll be asked to unplug/replug the radar once) 3. **K-LD7 radars** (deprecated; if you have them) — identifies each radar by plugging them in one at a time, so OpenFlight always knows which is which -4. **Auto-start on boot** — optional systemd service -5. **Desktop shortcut** — optional -6. **FlightWeb cloud sync** — optional uploader and device linking +4. **Geekworm UPS** (optional) — enables native X1202/X1206 battery and + external-power telemetry, Pi power settings, and desktop battery support +5. **Auto-start on boot** — optional systemd service +6. **Desktop shortcut** — optional +7. **FlightWeb cloud sync** — optional uploader and device linking Every step can be skipped and the script is **safe to re-run** any time — it picks up where you left off. @@ -162,6 +164,24 @@ what you flashed. > every capture. Use a separately powered USB hub for both radars instead > (operator guide, Option B). +### Geekworm X1202/X1206 UPS + +The optional UPS setup is automated and safe to rerun: + +```bash +sudo ./scripts/battery/geekworm/setup.sh +sudo reboot +./scripts/battery/geekworm/setup.sh --verify +``` + +It enables I2C, native Linux battery and charger devices, the Pi 5 EEPROM power +settings required by Geekworm, and the Raspberry Pi taskbar compatibility +package. It does not install automatic shutdown or charging-control services. + +Cell type, board-revision power limits, physical installation, every system +change, and troubleshooting are documented in the +**[Geekworm X1202/X1206 Operator Guide](battery/geekworm.md)**. + ### K-LD7 Device Names (Deprecated Hardware) Existing K-LD7 builds need stable `/dev/kld7_vertical` and diff --git a/scripts/battery/geekworm/setup.sh b/scripts/battery/geekworm/setup.sh new file mode 100755 index 000000000..12296fb50 --- /dev/null +++ b/scripts/battery/geekworm/setup.sh @@ -0,0 +1,446 @@ +#!/usr/bin/env bash +# Configure native X1202/X1206 telemetry and Raspberry Pi desktop display. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BATTERY_SCRIPT_DIR="$(dirname "$SCRIPT_DIR")" +BOOT_CONFIG="${OPENFLIGHT_GEEKWORM_BOOT_CONFIG:-/boot/firmware/config.txt}" +MODULES_CONFIG="${OPENFLIGHT_GEEKWORM_MODULES_CONFIG:-/etc/modules-load.d/openflight-geekworm.conf}" +POWER_SUPPLY_ROOT="${OPENFLIGHT_GEEKWORM_POWER_SUPPLY_ROOT:-/sys/class/power_supply}" +PI_MODEL_PATH="${OPENFLIGHT_GEEKWORM_PI_MODEL_PATH:-/proc/device-tree/model}" +PANEL_PACKAGE="$BATTERY_SCRIPT_DIR/packages/wfplug-batt_1.3+openflight2_arm64.deb" +PANEL_PACKAGE_VERSION="1.3+openflight2" +PANEL_PACKAGE_SHA256="d9bfeb459e13b328a1f77eeb82f5c387aa861d7c8a3337639a74c1288cd23e15" +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" + +I2C_LINE="dtparam=i2c_arm=on" +BATTERY_OVERLAY="dtoverlay=i2c-sensor,max17040" +CHARGER_OVERLAY="dtoverlay=gpio-charger,gpio=6,active_low=0,gpio_pull=down,type=mains" + +VERIFY_ONLY=false +NO_PANEL=false +REBOOT_REQUIRED=false +PANEL_RESTART_REQUIRED=false + +log() { + printf '[Geekworm] %s\n' "$*" +} + +warn() { + printf '[Geekworm] WARNING: %s\n' "$*" >&2 +} + +die() { + printf '[Geekworm] ERROR: %s\n' "$*" >&2 + return 1 +} + +usage() { + cat <<'EOF' +Usage: scripts/battery/geekworm/setup.sh [--verify] [--no-panel] + +Configure native Linux telemetry for Geekworm X1202/X1206 UPS boards. + + --verify Read-only verification after reboot + --no-panel Do not install or verify the Raspberry Pi taskbar patch + -h, --help Show this help + +The setup path does not install automatic-shutdown or charging-control services. +EOF +} + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +is_pi_5() { + [[ "${OPENFLIGHT_GEEKWORM_ALLOW_NON_PI:-0}" == "1" ]] && return 0 + [[ -r "$PI_MODEL_PATH" ]] && grep -aq "Raspberry Pi 5" "$PI_MODEL_PATH" +} + +os_codename() { + if [[ -r /etc/os-release ]]; then + awk -F= '$1 == "VERSION_CODENAME" { gsub(/"/, "", $2); print $2; exit }' \ + /etc/os-release + fi +} + +panel_package_supported() { + [[ "$(dpkg --print-architecture 2>/dev/null || true)" == "arm64" ]] \ + && [[ "$(os_codename)" == "trixie" ]] +} + +update_boot_config() { + local config_path="$1" + local conflicting_gpio + local -a missing=() + + [[ -f "$config_path" ]] || die "Boot configuration not found: $config_path" + + conflicting_gpio="$( + grep -E '^[[:space:]]*dtoverlay=gpio-charger([,[:space:]]|$)' "$config_path" \ + | grep -Fvx "$CHARGER_OVERLAY" || true + )" + if [[ -n "$conflicting_gpio" ]]; then + die "A different gpio-charger overlay already exists: $conflicting_gpio" + fi + + grep -Fqx "$I2C_LINE" "$config_path" || missing+=("$I2C_LINE") + grep -Fqx "$BATTERY_OVERLAY" "$config_path" || missing+=("$BATTERY_OVERLAY") + grep -Fqx "$CHARGER_OVERLAY" "$config_path" || missing+=("$CHARGER_OVERLAY") + + if (( ${#missing[@]} == 0 )); then + return 0 + fi + + { + printf '\n[all]\n' + printf '# OpenFlight Geekworm X1202/X1206 telemetry\n' + printf '%s\n' "${missing[@]}" + } >> "$config_path" +} + +set_key_value() { + local config_path="$1" + local key="$2" + local value="$3" + local updated + + updated="$(mktemp)" + awk -v key="$key" -v value="$value" ' + BEGIN { found = 0 } + $0 ~ ("^" key "=") { + if (!found) { + print key "=" value + found = 1 + } + next + } + { print } + END { + if (!found) + print key "=" value + } + ' "$config_path" > "$updated" + mv "$updated" "$config_path" +} + +update_eeprom_config() { + local config_path="$1" + + set_key_value "$config_path" "PSU_MAX_CURRENT" "5000" + set_key_value "$config_path" "POWER_OFF_ON_HALT" "1" +} + +install_required_packages() { + local -a missing=() + local package + + for package in i2c-tools upower; do + dpkg-query -W "$package" >/dev/null 2>&1 || missing+=("$package") + done + + if (( ${#missing[@]} == 0 )); then + log "Required OS packages are already installed." + return + fi + + log "Installing OS packages: ${missing[*]}" + as_root apt-get update + as_root apt-get install -y "${missing[@]}" +} + +configure_i2c_module() { + local current="" + local temp + + if [[ -r "$MODULES_CONFIG" ]]; then + current="$(<"$MODULES_CONFIG")" + fi + + if [[ "$current" == "i2c-dev" ]] \ + || { [[ "$MODULES_CONFIG" == /etc/modules-load.d/* ]] \ + && grep -RqsE '^[[:space:]]*i2c-dev([[:space:]#]|$)' \ + /etc/modules /etc/modules-load.d 2>/dev/null; }; then + log "i2c-dev boot module is already configured." + else + temp="$(mktemp)" + printf 'i2c-dev\n' > "$temp" + as_root install -D -m 0644 "$temp" "$MODULES_CONFIG" + rm -f "$temp" + log "Configured i2c-dev in $MODULES_CONFIG" + fi + + as_root modprobe i2c-dev +} + +configure_boot_overlays() { + local current + local updated + local backup + + [[ -r "$BOOT_CONFIG" ]] || die "Cannot read $BOOT_CONFIG" + + current="$(mktemp)" + updated="$(mktemp)" + as_root cp "$BOOT_CONFIG" "$current" + cp "$current" "$updated" + update_boot_config "$updated" + + if cmp -s "$current" "$updated"; then + log "I2C and power-supply overlays are already configured." + else + backup="${BOOT_CONFIG}.pre-openflight-geekworm-${TIMESTAMP}" + as_root cp -a "$BOOT_CONFIG" "$backup" + as_root cp "$updated" "$BOOT_CONFIG" + log "Updated $BOOT_CONFIG" + log "Boot configuration backup: $backup" + REBOOT_REQUIRED=true + fi + + rm -f "$current" "$updated" +} + +configure_eeprom() { + local current + local updated + local backup + + if ! command -v rpi-eeprom-config >/dev/null 2>&1; then + die "rpi-eeprom-config is unavailable; install current Raspberry Pi OS utilities" + fi + + current="$(mktemp)" + updated="$(mktemp)" + rpi-eeprom-config > "$current" + cp "$current" "$updated" + update_eeprom_config "$updated" + + if cmp -s "$current" "$updated"; then + log "Pi 5 EEPROM power settings are already configured." + else + backup="/boot/firmware/openflight-geekworm-eeprom-${TIMESTAMP}.conf" + as_root install -m 0644 "$current" "$backup" + as_root rpi-eeprom-config --apply "$updated" + log "Scheduled Pi 5 EEPROM power settings for the next reboot." + log "EEPROM configuration backup: $backup" + REBOOT_REQUIRED=true + fi + + rm -f "$current" "$updated" +} + +install_panel_package() { + local architecture + local actual_sha + local codename="" + local installed_version="" + + if [[ "$NO_PANEL" == "true" ]]; then + log "Skipping Raspberry Pi taskbar patch (--no-panel)." + return + fi + + architecture="$(dpkg --print-architecture)" + if [[ "$architecture" != "arm64" ]]; then + warn "The bundled taskbar package supports arm64 only; found $architecture." + warn "OpenFlight battery telemetry will still work." + return + fi + + codename="$(os_codename)" + if ! panel_package_supported; then + warn "The bundled taskbar package requires arm64 Raspberry Pi OS Trixie." + warn "Found architecture=$architecture codename=${codename:-unknown}; skipping the taskbar patch." + warn "OpenFlight battery telemetry will still work." + return + fi + + [[ -f "$PANEL_PACKAGE" ]] || die "Panel package not found: $PANEL_PACKAGE" + actual_sha="$(sha256sum "$PANEL_PACKAGE" | awk '{print $1}')" + [[ "$actual_sha" == "$PANEL_PACKAGE_SHA256" ]] \ + || die "Panel package checksum mismatch: $actual_sha" + + installed_version="$(dpkg-query -W -f='${Version}' wfplug-batt 2>/dev/null || true)" + if [[ "$installed_version" == "$PANEL_PACKAGE_VERSION" ]] \ + && dpkg -V wfplug-batt >/dev/null 2>&1; then + log "Raspberry Pi taskbar patch is already installed." + return + fi + + log "Installing Raspberry Pi taskbar capacity support." + if ! as_root apt-get install -y "$PANEL_PACKAGE"; then + die "The bundled panel package is incompatible with this OS; rerun with --no-panel" + fi + PANEL_RESTART_REQUIRED=true +} + +find_power_supply() { + local expected_type="$1" + local candidate + + for candidate in "$POWER_SUPPLY_ROOT"/*; do + [[ -d "$candidate" && -r "$candidate/type" ]] || continue + if [[ "$(<"$candidate/type")" == "$expected_type" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +verify_installation() { + local failures=0 + local battery="" + local charger="" + local capacity + local voltage + local online + local installed_version="" + local eeprom + local line + + log "Verifying Geekworm Raspberry Pi configuration..." + + for line in "$I2C_LINE" "$BATTERY_OVERLAY" "$CHARGER_OVERLAY"; do + if grep -Fqx "$line" "$BOOT_CONFIG" 2>/dev/null; then + log "Boot setting present: $line" + else + warn "Missing boot setting: $line" + failures=$((failures + 1)) + fi + done + + eeprom="$(rpi-eeprom-config 2>/dev/null || true)" + for line in "PSU_MAX_CURRENT=5000" "POWER_OFF_ON_HALT=1"; do + if grep -Fqx "$line" <<< "$eeprom"; then + log "EEPROM setting present: $line" + else + warn "Missing EEPROM setting: $line" + failures=$((failures + 1)) + fi + done + + if lsmod | grep -q '^max17040_battery'; then + log "Kernel module loaded: max17040_battery" + else + warn "max17040_battery is not loaded; reboot may still be required." + failures=$((failures + 1)) + fi + + if lsmod | grep -q '^gpio_charger'; then + log "Kernel module loaded: gpio_charger" + else + warn "gpio_charger is not loaded; reboot may still be required." + failures=$((failures + 1)) + fi + + battery="$(find_power_supply Battery || true)" + if [[ -n "$battery" && -r "$battery/capacity" && -r "$battery/voltage_now" ]]; then + capacity="$(<"$battery/capacity")" + voltage="$(<"$battery/voltage_now")" + log "Battery: capacity=${capacity}% voltage_now=${voltage}uV" + else + warn "Native Battery supply is unavailable under $POWER_SUPPLY_ROOT" + failures=$((failures + 1)) + fi + + charger="$(find_power_supply Mains || true)" + if [[ -n "$charger" && -r "$charger/online" ]]; then + online="$(<"$charger/online")" + log "External power: online=$online" + else + warn "Native Mains supply is unavailable under $POWER_SUPPLY_ROOT" + failures=$((failures + 1)) + fi + + if command -v upower >/dev/null 2>&1; then + if upower -e | grep -q '/battery_'; then + log "UPower discovered the battery." + else + warn "UPower has not discovered the battery yet." + fi + fi + + if [[ "$NO_PANEL" != "true" ]] && panel_package_supported; then + installed_version="$(dpkg-query -W -f='${Version}' wfplug-batt 2>/dev/null || true)" + if [[ "$installed_version" == "$PANEL_PACKAGE_VERSION" ]] \ + && dpkg -V wfplug-batt >/dev/null 2>&1; then + log "Taskbar package verified: wfplug-batt $installed_version" + else + warn "Taskbar package is not the verified OpenFlight build (found: ${installed_version:-missing})." + failures=$((failures + 1)) + fi + elif [[ "$NO_PANEL" != "true" ]]; then + warn "Skipping taskbar package verification on this OS/architecture." + fi + + if command -v vcgencmd >/dev/null 2>&1; then + log "Pi power flags: $(vcgencmd get_throttled)" + fi + + if (( failures > 0 )); then + die "Verification failed with $failures issue(s)." + return 1 + fi + + log "Geekworm power telemetry is ready." +} + +main() { + while (( $# > 0 )); do + case "$1" in + --verify) + VERIFY_ONLY=true + ;; + --no-panel) + NO_PANEL=true + ;; + -h|--help) + usage + return 0 + ;; + *) + usage >&2 + die "Unknown option: $1" + ;; + esac + shift + done + + is_pi_5 || die "This setup supports Raspberry Pi 5 only." + + if [[ "$VERIFY_ONLY" == "true" ]]; then + verify_installation + return + fi + + if (( EUID != 0 )); then + sudo -v + fi + + log "Configuring X1202/X1206 native power telemetry." + install_required_packages + configure_i2c_module + configure_boot_overlays + configure_eeprom + install_panel_package + + log "No automatic-shutdown or charging-control service was installed." + if [[ "$REBOOT_REQUIRED" == "true" ]]; then + log "Reboot required. After reboot, run: scripts/battery/geekworm/setup.sh --verify" + elif [[ "$PANEL_RESTART_REQUIRED" == "true" ]]; then + log "Restart the desktop panel or reboot to load the taskbar update." + else + verify_installation + fi +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/battery/packages/wfplug-batt_1.3+openflight2_arm64.deb b/scripts/battery/packages/wfplug-batt_1.3+openflight2_arm64.deb new file mode 100644 index 000000000..da024a918 Binary files /dev/null and b/scripts/battery/packages/wfplug-batt_1.3+openflight2_arm64.deb differ diff --git a/scripts/battery/patches/wfplug-batt-capacity.patch b/scripts/battery/patches/wfplug-batt-capacity.patch new file mode 100644 index 000000000..5b958e5a8 --- /dev/null +++ b/scripts/battery/patches/wfplug-batt-capacity.patch @@ -0,0 +1,86 @@ +diff --git a/src/batt_sys.c b/src/batt_sys.c +index 332838c..d9f8515 100644 +--- a/src/batt_sys.c ++++ b/src/batt_sys.c +@@ -50,6 +50,7 @@ battery* battery_new() { + b->charge_full_design = -1; + b->energy_now = -1; + b->charge_now = -1; ++ b->capacity = -1; + b->current_now = -1; + b->power_now = -1; + b->state = NULL; +@@ -94,6 +95,18 @@ static gint get_gint_from_infofile(battery *b, gchar *sys_file) + return value; + } + ++static gint get_raw_gint_from_infofile(battery *b, gchar *sys_file) ++{ ++ gchar *file_content = parse_info_file(b, sys_file); ++ gint value = -1; ++ ++ if (file_content != NULL) ++ value = atoi(file_content); ++ g_free(file_content); ++ ++ return value; ++} ++ + static gchar* get_gchar_from_infofile(battery *b, gchar *sys_file) + { + return parse_info_file(b, sys_file); +@@ -178,6 +191,7 @@ battery* battery_update(battery *b) + /* read from sysfs */ + b->charge_now = get_gint_from_infofile(b, "charge_now"); + b->energy_now = get_gint_from_infofile(b, "energy_now"); ++ b->capacity = get_raw_gint_from_infofile(b, "capacity"); + + b->current_now = get_gint_from_infofile(b, "current_now"); + b->power_now = get_gint_from_infofile(b, "power_now"); +@@ -206,7 +220,7 @@ battery* battery_update(battery *b) + if (!b->state) + b->state = get_gchar_from_infofile(b, "state"); + if (!b->state) { +- if (b->charge_now != -1 || b->energy_now != -1 ++ if (b->capacity != -1 || b->charge_now != -1 || b->energy_now != -1 + || b->charge_full != -1 || b->energy_full != -1) + b->state = g_strdup("available"); + else +@@ -256,15 +270,22 @@ battery* battery_update(battery *b) + } + #endif + +- if (b->charge_now != -1 && b->charge_full != -1) ++ if (b->capacity >= 0) ++ b->percentage = b->capacity; ++ else if (b->charge_now != -1 && b->charge_full != -1) ++ { + promille = (b->charge_now * 1000) / b->charge_full; ++ b->percentage = (promille + 5) / 10; /* round properly */ ++ } + else if (b->energy_full != -1 && b->energy_now != -1) ++ { + /* no charge data, let try energy instead */ + promille = (b->energy_now * 1000) / b->energy_full; ++ b->percentage = (promille + 5) / 10; /* round properly */ ++ } + else +- promille = 0; ++ b->percentage = 0; + +- b->percentage = (promille + 5) / 10; /* round properly */ + if (b->percentage > 100) + b->percentage = 100; + +diff --git a/src/batt_sys.h b/src/batt_sys.h +index acca27b..6c88681 100644 +--- a/src/batt_sys.h ++++ b/src/batt_sys.h +@@ -41,6 +41,7 @@ typedef struct battery { + /* sysfs file contents */ + int charge_now; + int energy_now; ++ int capacity; + int current_now; + int power_now; + int voltage_now; diff --git a/scripts/setup/setup.sh b/scripts/setup/setup.sh index 88821355d..4dc01396d 100755 --- a/scripts/setup/setup.sh +++ b/scripts/setup/setup.sh @@ -6,6 +6,7 @@ # one-time hardware configuration interactively: # - OPS243-A rolling buffer flash config # - K-LD7 device naming + FTDI low-latency rules +# - Optional battery-provider telemetry # - Auto-start on boot (systemd service) # - Desktop shortcut # @@ -191,7 +192,7 @@ log "UI built ✓" cd .. # Make scripts executable -chmod +x scripts/*.sh scripts/setup/*.sh +chmod +x scripts/*.sh scripts/setup/*.sh scripts/battery/*/*.sh # Run tests to verify installation log "Running tests to verify installation..." @@ -245,6 +246,16 @@ if [ "$PLATFORM" == "pi" ] && [ "$DEPS_ONLY" == "false" ] && [ "$INTERACTIVE" == info "Skipped. Run later with: ./scripts/setup/setup_kld7_devices.sh" fi + # --- Geekworm X1202/X1206 power telemetry --- + echo "" + if confirm "Do you have a Geekworm X1202 or X1206 UPS to set up?" "N"; then + "$PROJECT_DIR/scripts/battery/geekworm/setup.sh" + info "Reboot before verifying Geekworm telemetry with:" + info " ./scripts/battery/geekworm/setup.sh --verify" + else + info "Skipped. Run later with: ./scripts/battery/geekworm/setup.sh" + fi + # --- Auto-start service --- echo "" if confirm "Start OpenFlight automatically on boot?" "N"; then @@ -326,6 +337,7 @@ echo "" log "Start OpenFlight:" echo " ./scripts/start-kiosk.sh # Default: rolling buffer + sound trigger" echo " ./scripts/start-kiosk.sh --kld7 # With K-LD7 angle radars" +echo " ./scripts/start-kiosk.sh --battery geekworm # With Geekworm battery display" echo " ./scripts/start-kiosk.sh --mock # Mock mode (no hardware)" echo "" log "Then open http://localhost:8080 (or use the touchscreen)." diff --git a/scripts/start-kiosk.sh b/scripts/start-kiosk.sh index 70f943317..4683c91b0 100755 --- a/scripts/start-kiosk.sh +++ b/scripts/start-kiosk.sh @@ -65,6 +65,7 @@ EXPERIMENTAL_KLD7_HORIZONTAL_ANGLE_LIMIT="" BALLISTICS=true SIM=false CALCULATED_SPIN=false +BATTERY_PROVIDER="" SWING_SPEED=false SWING_SPEED_THRESHOLD="" SWING_SPEED_MIN_READINGS="" @@ -106,6 +107,10 @@ while [[ $# -gt 0 ]]; do RADAR_LOG=true shift ;; + --battery) + BATTERY_PROVIDER="$2" + shift 2 + ;; --debug|-d) DEBUG_MODE=true shift @@ -539,6 +544,10 @@ if [ "$NO_CAMERA" = true ]; then SERVER_CMD="$SERVER_CMD --no-camera" fi +if [ -n "$BATTERY_PROVIDER" ]; then + SERVER_CMD="$SERVER_CMD --battery $BATTERY_PROVIDER" +fi + if [ "$BALLISTICS" = false ]; then SERVER_CMD="$SERVER_CMD --no-ballistics" fi diff --git a/src/openflight/power/__init__.py b/src/openflight/power/__init__.py new file mode 100644 index 000000000..6de5bfe47 --- /dev/null +++ b/src/openflight/power/__init__.py @@ -0,0 +1,26 @@ +"""Power monitoring for supported OpenFlight installations.""" + +from .factory import ( + SUPPORTED_BATTERY_PROVIDERS, + BatteryProvider, + create_power_reader, + normalize_battery_provider, +) +from .models import PowerSample, PowerState, PowerStatus +from .providers import GeekwormPowerReader, LinuxPowerReader +from .reader import PowerReader +from .service import PowerMonitor + +__all__ = [ + "SUPPORTED_BATTERY_PROVIDERS", + "BatteryProvider", + "GeekwormPowerReader", + "LinuxPowerReader", + "PowerMonitor", + "PowerReader", + "PowerSample", + "PowerState", + "PowerStatus", + "create_power_reader", + "normalize_battery_provider", +] diff --git a/src/openflight/power/factory.py b/src/openflight/power/factory.py new file mode 100644 index 000000000..3526eb514 --- /dev/null +++ b/src/openflight/power/factory.py @@ -0,0 +1,52 @@ +"""Battery provider selection and reader construction.""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from pathlib import Path + +from .providers.geekworm import GeekwormPowerReader +from .providers.linux import LinuxPowerReader +from .reader import PowerReader + + +class BatteryProvider(str, Enum): + """Battery hardware integrations supported by OpenFlight.""" + + GEEKWORM = "geekworm" + + +SUPPORTED_BATTERY_PROVIDERS = tuple(provider.value for provider in BatteryProvider) + + +def normalize_battery_provider(provider: BatteryProvider | str) -> BatteryProvider: + """Validate and normalize a provider name.""" + try: + return BatteryProvider(provider) + except ValueError as error: + supported = ", ".join(SUPPORTED_BATTERY_PROVIDERS) + raise ValueError( + f"Unsupported battery provider {provider!r}; expected one of: {supported}" + ) from error + + +def create_power_reader( + provider: BatteryProvider | str, + *, + power_supply_path: Path = LinuxPowerReader.DEFAULT_POWER_SUPPLY_PATH, + provider_factory: Callable[[], PowerReader] | None = None, +) -> PowerReader: + """Prefer standard Linux telemetry and fall back to the selected provider.""" + normalized = normalize_battery_provider(provider) + try: + return LinuxPowerReader(power_supply_path=power_supply_path) + except OSError: + pass + + if provider_factory is not None: + return provider_factory() + if normalized is BatteryProvider.GEEKWORM: + return GeekwormPowerReader() + + raise AssertionError(f"No reader factory registered for {normalized.value}") diff --git a/src/openflight/power/models.py b/src/openflight/power/models.py new file mode 100644 index 000000000..998292e7b --- /dev/null +++ b/src/openflight/power/models.py @@ -0,0 +1,46 @@ +"""Shared battery and external-power telemetry models.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import Enum +from typing import Any + + +class PowerState(str, Enum): + """User-facing power states emitted to the OpenFlight UI.""" + + PLUGGED_IN = "plugged_in" + ON_BATTERY = "on_battery" + LOW = "low" + CRITICAL = "critical" + UNAVAILABLE = "unavailable" + + +@dataclass(frozen=True) +class PowerSample: + """Minimum telemetry shared by every supported battery provider.""" + + battery_percent: float + battery_voltage_v: float + external_power: bool + + +@dataclass(frozen=True) +class PowerStatus: + """Power state sent to clients and session logs.""" + + available: bool + provider: str + state: PowerState + battery_percent: float | None + battery_voltage_v: float | None + external_power: bool | None + updated_at: str + error: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Return a JSON-compatible status payload.""" + payload = asdict(self) + payload["state"] = self.state.value + return payload diff --git a/src/openflight/power/providers/__init__.py b/src/openflight/power/providers/__init__.py new file mode 100644 index 000000000..592e582a6 --- /dev/null +++ b/src/openflight/power/providers/__init__.py @@ -0,0 +1,6 @@ +"""Hardware-specific battery telemetry providers.""" + +from .geekworm import GeekwormPowerReader +from .linux import LinuxPowerReader + +__all__ = ["GeekwormPowerReader", "LinuxPowerReader"] diff --git a/src/openflight/power/providers/geekworm.py b/src/openflight/power/providers/geekworm.py new file mode 100644 index 000000000..769e29dad --- /dev/null +++ b/src/openflight/power/providers/geekworm.py @@ -0,0 +1,119 @@ +"""Low-level reader for Geekworm X1202 and X1206 UPS boards.""" + +from __future__ import annotations + +from typing import Protocol + +from ..models import PowerSample + + +class SMBusLike(Protocol): # pylint: disable=unnecessary-ellipsis + """Subset of smbus2 used by the MAX17043 fuel gauge reader.""" + + def read_i2c_block_data(self, address: int, register: int, length: int) -> list[int]: + """Read a contiguous register block.""" + ... # pylint: disable=unnecessary-ellipsis + + def close(self) -> None: + """Close the bus.""" + ... # pylint: disable=unnecessary-ellipsis + + +class DigitalInputLike(Protocol): # pylint: disable=unnecessary-ellipsis + """Subset of gpiozero DigitalInputDevice used for AC detection.""" + + @property + def value(self) -> int: + """Return the current digital input value.""" + ... # pylint: disable=unnecessary-ellipsis + + def close(self) -> None: + """Release the input.""" + ... # pylint: disable=unnecessary-ellipsis + + +class GeekwormPowerReader: + """Read battery state shared by the X1202 and X1206. + + Both boards expose a MAX17043-compatible fuel gauge at I2C address 0x36 + and assert GPIO6 high while an external power adapter is present. + """ + + DEFAULT_ADDRESS = 0x36 + AC_DETECT_PIN = 6 + VCELL_REGISTER = 0x02 + SOC_REGISTER = 0x04 + + def __init__( + self, + *, + bus_number: int = 1, + address: int = DEFAULT_ADDRESS, + bus: SMBusLike | None = None, + ac_input: DigitalInputLike | None = None, + ): + owns_bus = bus is None + if bus is None: + from smbus2 import SMBus # pylint: disable=import-error,import-outside-toplevel + + bus = SMBus(bus_number) + if ac_input is None: + try: + from gpiozero import ( # pylint: disable=import-error,import-outside-toplevel + DigitalInputDevice, + ) + + from ...gpio_factory import ensure_lgpio_pin_factory + + ensure_lgpio_pin_factory() + # GPIO6 is high when the adapter is present. A pull-down keeps + # a disconnected or failed pogo contact in the safe "unplugged" state. + ac_input = DigitalInputDevice(self.AC_DETECT_PIN, pull_up=False) + except Exception: + if owns_bus: + bus.close() + raise + + self.bus = bus + self.ac_input = ac_input + self.address = address + self._closed = False + + def _read_register(self, register: int) -> tuple[int, int]: + data = self.bus.read_i2c_block_data(self.address, register, 2) + if len(data) != 2: + raise OSError( + f"Geekworm fuel gauge register 0x{register:02x} returned " + f"{len(data)} bytes; expected 2" + ) + return data[0], data[1] + + def read(self) -> PowerSample: + """Read and validate battery percentage, voltage, and adapter state.""" + voltage_msb, voltage_lsb = self._read_register(self.VCELL_REGISTER) + soc_msb, soc_lsb = self._read_register(self.SOC_REGISTER) + + voltage_raw = (voltage_msb << 4) | (voltage_lsb >> 4) + voltage_v = voltage_raw * 0.00125 + battery_percent = soc_msb + (soc_lsb / 256.0) + + if not 0.0 <= voltage_v <= 5.0: + raise OSError(f"Geekworm battery voltage is invalid: {voltage_v:.3f}V") + if not 0.0 <= battery_percent <= 110.0: + raise OSError(f"Geekworm battery percentage is invalid: {battery_percent:.2f}%") + + return PowerSample( + battery_percent=min(100.0, battery_percent), + battery_voltage_v=voltage_v, + external_power=bool(self.ac_input.value), + ) + + def close(self) -> None: + """Release the I2C and GPIO resources.""" + if self._closed: + return + self._closed = True + try: + self.ac_input.close() + finally: + self.bus.close() diff --git a/src/openflight/power/providers/linux.py b/src/openflight/power/providers/linux.py new file mode 100644 index 000000000..e38d87df0 --- /dev/null +++ b/src/openflight/power/providers/linux.py @@ -0,0 +1,68 @@ +"""Reader for battery telemetry managed by Linux power_supply drivers.""" + +from __future__ import annotations + +import math +from pathlib import Path + +from ..models import PowerSample + + +class LinuxPowerReader: + """Read battery and adapter state exposed by Linux power_supply drivers.""" + + DEFAULT_POWER_SUPPLY_PATH = Path("/sys/class/power_supply") + + def __init__(self, *, power_supply_path: Path = DEFAULT_POWER_SUPPLY_PATH): + self.power_supply_path = power_supply_path + self.battery_path = self._find_supply( + "Battery", + required_files=("capacity", "voltage_now"), + ) + self.mains_path = self._find_supply("Mains", required_files=("online",)) + + def _find_supply(self, supply_type: str, *, required_files: tuple[str, ...]) -> Path: + if not self.power_supply_path.is_dir(): + raise OSError(f"Linux power supply path is unavailable: {self.power_supply_path}") + + for candidate in sorted(self.power_supply_path.iterdir()): + try: + candidate_type = (candidate / "type").read_text(encoding="ascii").strip() + except (OSError, UnicodeError): + continue + if candidate_type == supply_type and all( + (candidate / filename).is_file() for filename in required_files + ): + return candidate + + raise OSError(f"Linux {supply_type} power supply is unavailable") + + @staticmethod + def _read_number(path: Path) -> float: + try: + return float(path.read_text(encoding="ascii").strip()) + except (OSError, UnicodeError, ValueError) as error: + raise OSError(f"Could not read Linux power telemetry from {path}: {error}") from error + + def read(self) -> PowerSample: + """Read and validate one sample from Linux sysfs.""" + battery_percent = self._read_number(self.battery_path / "capacity") + battery_voltage_v = self._read_number(self.battery_path / "voltage_now") / 1_000_000 + external_power_raw = self._read_number(self.mains_path / "online") + + if not math.isfinite(battery_percent) or battery_percent < 0.0: + raise OSError(f"Linux battery percentage is invalid: {battery_percent:.2f}%") + battery_percent = min(battery_percent, 100.0) + if not 0.0 <= battery_voltage_v <= 5.0: + raise OSError(f"Linux battery voltage is invalid: {battery_voltage_v:.3f}V") + if external_power_raw not in (0.0, 1.0): + raise OSError(f"Linux external power state is invalid: {external_power_raw}") + + return PowerSample( + battery_percent=battery_percent, + battery_voltage_v=battery_voltage_v, + external_power=bool(external_power_raw), + ) + + def close(self) -> None: + """Native sysfs reads do not hold resources between samples.""" diff --git a/src/openflight/power/reader.py b/src/openflight/power/reader.py new file mode 100644 index 000000000..e406ff193 --- /dev/null +++ b/src/openflight/power/reader.py @@ -0,0 +1,17 @@ +"""Common reader contract for battery and external-power providers.""" + +from typing import Protocol + +from .models import PowerSample + + +class PowerReader(Protocol): # pylint: disable=unnecessary-ellipsis + """Hardware-independent telemetry reader used by the power monitor.""" + + def read(self) -> PowerSample: + """Read one battery and external-power sample.""" + ... # pylint: disable=unnecessary-ellipsis + + def close(self) -> None: + """Release resources held by the reader.""" + ... # pylint: disable=unnecessary-ellipsis diff --git a/src/openflight/power/service.py b/src/openflight/power/service.py new file mode 100644 index 000000000..da388b3a6 --- /dev/null +++ b/src/openflight/power/service.py @@ -0,0 +1,151 @@ +"""Background battery monitor with retry and log throttling.""" + +from __future__ import annotations + +import logging +import threading +import time +from datetime import datetime, timezone +from typing import Callable + +from .factory import BatteryProvider, create_power_reader, normalize_battery_provider +from .models import PowerSample, PowerState, PowerStatus +from .reader import PowerReader + +logger = logging.getLogger(__name__) + + +class PowerMonitor: + """Poll one battery provider and publish application-level status.""" + + def __init__( # pylint: disable=too-many-arguments + self, + *, + provider: BatteryProvider | str, + on_status: Callable[[PowerStatus], None], + on_log: Callable[[PowerStatus], None] | None = None, + reader_factory: Callable[[], PowerReader] | None = None, + poll_interval_s: float = 5.0, + log_interval_s: float = 60.0, + clock: Callable[[], float] = time.monotonic, + ): + self.provider = normalize_battery_provider(provider) + self.on_status = on_status + self.on_log = on_log + self.reader_factory = reader_factory or (lambda: create_power_reader(self.provider)) + self.poll_interval_s = poll_interval_s + self.log_interval_s = log_interval_s + self.clock = clock + self._reader: PowerReader | None = None + self._status: PowerStatus | None = None + self._last_log_key: tuple[PowerState, bool | None] | None = None + self._last_log_at: float | None = None + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + + @property + def status(self) -> PowerStatus | None: + """Return the most recently published status.""" + with self._lock: + return self._status + + @staticmethod + def _state_for(sample: PowerSample) -> PowerState: + if sample.external_power: + return PowerState.PLUGGED_IN + if sample.battery_percent <= 10.0: + return PowerState.CRITICAL + if sample.battery_percent <= 20.0: + return PowerState.LOW + return PowerState.ON_BATTERY + + @staticmethod + def _timestamp() -> str: + return datetime.now(timezone.utc).isoformat() + + def _available_status(self, sample: PowerSample) -> PowerStatus: + return PowerStatus( + available=True, + provider=self.provider.value, + state=self._state_for(sample), + battery_percent=round(sample.battery_percent, 1), + battery_voltage_v=round(sample.battery_voltage_v, 3), + external_power=sample.external_power, + updated_at=self._timestamp(), + ) + + def _unavailable_status(self, error: Exception) -> PowerStatus: + return PowerStatus( + available=False, + provider=self.provider.value, + state=PowerState.UNAVAILABLE, + battery_percent=None, + battery_voltage_v=None, + external_power=None, + updated_at=self._timestamp(), + error=str(error), + ) + + def _close_reader(self) -> None: + if self._reader is None: + return + try: + self._reader.close() + except Exception: + logger.warning("[POWER] Failed to close battery reader", exc_info=True) + finally: + self._reader = None + + def poll_once(self) -> PowerStatus: + """Read and publish one status, recovering from hardware failures.""" + try: + if self._reader is None: + self._reader = self.reader_factory() + status = self._available_status(self._reader.read()) + except Exception as error: # Hardware access must never stop OpenFlight. + logger.warning("[POWER] %s telemetry unavailable: %s", self.provider.value, error) + self._close_reader() + status = self._unavailable_status(error) + + with self._lock: + self._status = status + + try: + self.on_status(status) + except Exception: + logger.warning("[POWER] Status callback failed", exc_info=True) + + now = self.clock() + log_key = (status.state, status.external_power) + log_due = self._last_log_at is None or now - self._last_log_at >= self.log_interval_s + if self.on_log and (log_key != self._last_log_key or log_due): + try: + self.on_log(status) + self._last_log_key = log_key + self._last_log_at = now + except Exception: + logger.warning("[POWER] Session log callback failed", exc_info=True) + + return status + + def _run(self) -> None: + while not self._stop_event.is_set(): + self.poll_once() + self._stop_event.wait(self.poll_interval_s) + self._close_reader() + + def start(self) -> None: + """Start polling in a daemon thread.""" + if self._thread and self._thread.is_alive(): + return + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, name="battery-power", daemon=True) + self._thread.start() + + def stop(self) -> None: + """Stop polling and release hardware resources.""" + self._stop_event.set() + if self._thread and self._thread is not threading.current_thread(): + self._thread.join(timeout=max(1.0, self.poll_interval_s + 1.0)) + self._close_reader() diff --git a/src/openflight/server.py b/src/openflight/server.py index 80291d44c..5fdae2dfa 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -30,6 +30,7 @@ SpeedReading, set_show_raw_readings, ) +from .power import SUPPORTED_BATTERY_PROVIDERS, PowerMonitor, PowerStatus from .rolling_buffer.monitor import estimate_carry_with_spin, get_optimal_spin_for_ball_speed from .session_logger import get_session_logger, init_session_logger, log_session_error from .sim import ( @@ -79,6 +80,8 @@ # Global state monitor = None +power_monitor: Optional[PowerMonitor] = None +battery_provider: str | None = None mock_mode: bool = False debug_mode: bool = False mock_swing_speed_mode: bool = False @@ -206,6 +209,8 @@ def _cleanup_hardware_for_shutdown() -> bool: _run_shutdown_step("inclinometer stop", inclinometer_service.stop) if iwr6843_runtime: _run_shutdown_step("IWR6843 stop", iwr6843_runtime.stop) + if power_monitor: + _run_shutdown_step("battery monitor stop", power_monitor.stop) _run_shutdown_step("camera thread stop", stop_camera_thread) if camera: @@ -859,6 +864,10 @@ def _session_start_config() -> dict: } config["iwr6843"] = dict(iwr6843_runtime_config) config["inclinometer"] = dict(inclinometer_runtime_config) + config["power"] = { + "enabled": battery_provider is not None, + "provider": battery_provider, + } return config @@ -1658,11 +1667,37 @@ def _emit_sim_snapshot() -> None: ) +def _on_power_status(status: PowerStatus) -> None: + """Publish one battery reading to connected UI clients.""" + socketio.emit("power_status", status.to_dict()) + + +def _log_power_status(status: PowerStatus) -> None: + """Write throttled battery telemetry into the active session log.""" + session_log = get_session_logger() + if session_log: + session_log.log_power_status(status.to_dict()) + + +def start_power_monitor(provider: str) -> None: + """Start optional battery monitoring without blocking server startup.""" + global power_monitor # pylint: disable=global-statement + power_monitor = PowerMonitor( + provider=provider, + on_status=_on_power_status, + on_log=_log_power_status, + ) + power_monitor.start() + logger.info("[POWER] Battery monitoring enabled with provider=%s", provider) + + @socketio.on("connect") def handle_connect(): """Handle client connection.""" print("Client connected") _emit_sim_snapshot() + if power_monitor and power_monitor.status: + socketio.emit("power_status", power_monitor.status.to_dict()) if monitor: stats = monitor.get_session_stats() socketio.emit( @@ -3548,6 +3583,16 @@ def _add_ballistics_arguments(parser): parser.set_defaults(ballistics=True) +def _add_battery_arguments(parser): + """Add explicit battery-provider selection.""" + parser.add_argument( + "--battery", + choices=SUPPORTED_BATTERY_PROVIDERS, + default=None, + help="Show battery and external-power status using the selected provider", + ) + + def main(): """Run the server.""" import argparse # pylint: disable=import-outside-toplevel @@ -3639,6 +3684,7 @@ def main(): "--log-dir", help="Directory for session logs (default: ~/openflight_sessions)" ) parser.add_argument("--no-logging", action="store_true", help="Disable session logging") + _add_battery_arguments(parser) parser.add_argument( "--sim", action="store_true", @@ -4031,6 +4077,7 @@ def main(): global experimental_kld7_raw_radc_logging global active_kld7_radc_tuning global ballistics_enabled + global battery_provider experimental_kld7_raw_radc_logging = args.experimental_kld7_raw_radc_logging experimental_kld7_radc_tuning = args.experimental_kld7_radc_tuning global ball_speed_correction_enabled @@ -4046,6 +4093,7 @@ def main(): global calculated_spin_enabled calculated_spin_enabled = args.calculated_spin ballistics_enabled = args.ballistics + battery_provider = args.battery kld7_radc_tuning_kwargs = _kld7_radc_tuning_kwargs(args) active_kld7_radc_tuning = dict(kld7_radc_tuning_kwargs) @@ -4237,6 +4285,10 @@ def main(): ops_baud=args.ops_baud, ) + if battery_provider: + start_power_monitor(battery_provider) + print(f"Battery monitoring: ENABLED ({battery_provider})") + # Simulator connectors (off unless --sim). Started after the monitor exists # so inbound club updates can call monitor.set_club(). global sim_connectors # pylint: disable=global-statement diff --git a/src/openflight/session_logger.py b/src/openflight/session_logger.py index 15a9a118d..baaed96fc 100644 --- a/src/openflight/session_logger.py +++ b/src/openflight/session_logger.py @@ -660,6 +660,12 @@ def log_sim_player(self, target: str, handed: str, club: str): }, ) + def log_power_status(self, status: Dict[str, Any]) -> None: + """Log a battery snapshot or power-state transition.""" + if not self.enabled: + return + self._write_entry("power_status", status) + def log_iq_reading( self, speed_mph: float, diff --git a/tests/test_geekworm_power.py b/tests/test_geekworm_power.py new file mode 100644 index 000000000..8f0176160 --- /dev/null +++ b/tests/test_geekworm_power.py @@ -0,0 +1,266 @@ +"""Tests for X1202/X1206 battery and adapter telemetry.""" + +import sys +from types import ModuleType + +from openflight import gpio_factory +from openflight.power import ( + BatteryProvider, + GeekwormPowerReader, + LinuxPowerReader, + PowerMonitor, + PowerSample, + PowerState, + create_power_reader, +) + + +class FakeBus: + def __init__(self, registers): + self.registers = registers + self.closed = False + + def read_i2c_block_data(self, address, register, length): + assert address == 0x36 + assert length == 2 + return self.registers[register] + + def close(self): + self.closed = True + + +class FakeInput: + def __init__(self, value): + self.value = value + self.closed = False + + def close(self): + self.closed = True + + +class SequenceReader: + def __init__(self, outcomes): + self.outcomes = iter(outcomes) + self.closed = False + + def read(self): + outcome = next(self.outcomes) + if isinstance(outcome, Exception): + raise outcome + return outcome + + def close(self): + self.closed = True + + +def test_reader_decodes_max17043_voltage_soc_and_adapter_state(): + bus = FakeBus({0x02: [0xC3, 0x00], 0x04: [65, 128]}) + ac_input = FakeInput(1) + reader = GeekwormPowerReader(bus=bus, ac_input=ac_input) + + sample = reader.read() + + assert sample.battery_voltage_v == 3.9 + assert sample.battery_percent == 65.5 + assert sample.external_power is True + + +def test_reader_releases_i2c_and_gpio_resources_once(): + bus = FakeBus({0x02: [0, 0], 0x04: [0, 0]}) + ac_input = FakeInput(0) + reader = GeekwormPowerReader(bus=bus, ac_input=ac_input) + + reader.close() + reader.close() + + assert bus.closed is True + assert ac_input.closed is True + + +def test_reader_treats_gpio6_as_active_high_with_a_fail_safe_pull_down(monkeypatch): + created = {} + + class SpyInput(FakeInput): + def __init__(self, pin, **kwargs): + if kwargs.get("pull_up") is not None and kwargs.get("active_state") is not None: + raise ValueError('Pin GPIO6 is not floating, but "active_state" is not None') + super().__init__(value=1) + created.update(pin=pin, **kwargs) + + fake_gpiozero = ModuleType("gpiozero") + fake_gpiozero.DigitalInputDevice = SpyInput + monkeypatch.setitem(sys.modules, "gpiozero", fake_gpiozero) + monkeypatch.setattr(gpio_factory, "ensure_lgpio_pin_factory", lambda: None) + + reader = GeekwormPowerReader( + bus=FakeBus({0x02: [0xC3, 0x00], 0x04: [50, 0]}), + ) + + assert created == {"pin": 6, "pull_up": False} + assert reader.read().external_power is True + + +def test_native_reader_discovers_linux_battery_and_mains_devices(tmp_path): + battery = tmp_path / "battery" + battery.mkdir() + (battery / "type").write_text("Battery\n", encoding="ascii") + (battery / "capacity").write_text("47\n", encoding="ascii") + (battery / "voltage_now").write_text("3787500\n", encoding="ascii") + + mains = tmp_path / "charger@0" + mains.mkdir() + (mains / "type").write_text("Mains\n", encoding="ascii") + (mains / "online").write_text("1\n", encoding="ascii") + + reader = LinuxPowerReader(power_supply_path=tmp_path) + + assert reader.read() == PowerSample( + battery_percent=47.0, + battery_voltage_v=3.7875, + external_power=True, + ) + + +def test_native_reader_clamps_small_capacity_overshoot_to_full(tmp_path): + battery = tmp_path / "battery" + battery.mkdir() + (battery / "type").write_text("Battery\n", encoding="ascii") + (battery / "capacity").write_text("101\n", encoding="ascii") + (battery / "voltage_now").write_text("4200000\n", encoding="ascii") + + mains = tmp_path / "charger" + mains.mkdir() + (mains / "type").write_text("Mains\n", encoding="ascii") + (mains / "online").write_text("1\n", encoding="ascii") + + reader = LinuxPowerReader(power_supply_path=tmp_path) + + assert reader.read() == PowerSample( + battery_percent=100.0, + battery_voltage_v=4.2, + external_power=True, + ) + + +def test_native_reader_requires_battery_and_mains_devices(tmp_path): + battery = tmp_path / "battery" + battery.mkdir() + (battery / "type").write_text("Battery\n", encoding="ascii") + (battery / "capacity").write_text("47\n", encoding="ascii") + (battery / "voltage_now").write_text("3787500\n", encoding="ascii") + + try: + LinuxPowerReader(power_supply_path=tmp_path) + except OSError as error: + assert "Mains" in str(error) + else: + raise AssertionError("missing native mains device should fail") + + +def test_reader_factory_prefers_native_power_supply(tmp_path): + battery = tmp_path / "battery" + battery.mkdir() + (battery / "type").write_text("Battery\n", encoding="ascii") + (battery / "capacity").write_text("47\n", encoding="ascii") + (battery / "voltage_now").write_text("3787500\n", encoding="ascii") + mains = tmp_path / "charger@0" + mains.mkdir() + (mains / "type").write_text("Mains\n", encoding="ascii") + (mains / "online").write_text("1\n", encoding="ascii") + + selected = create_power_reader( + BatteryProvider.GEEKWORM, + power_supply_path=tmp_path, + provider_factory=lambda: (_ for _ in ()).throw(AssertionError("direct reader used")), + ) + + assert isinstance(selected, LinuxPowerReader) + + +def test_reader_factory_falls_back_when_native_power_supply_is_missing(tmp_path): + direct_reader = SequenceReader([PowerSample(12.0, 3.5, False)]) + + selected = create_power_reader( + BatteryProvider.GEEKWORM, + power_supply_path=tmp_path, + provider_factory=lambda: direct_reader, + ) + + assert selected is direct_reader + + +def test_reader_factory_rejects_unknown_provider(): + try: + create_power_reader("unknown") + except ValueError as error: + assert "Unsupported battery provider" in str(error) + else: + raise AssertionError("unknown battery provider should fail") + + +def test_monitor_classifies_plugged_low_and_critical_states(): + samples = [ + PowerSample(50.0, 3.8, True), + PowerSample(20.0, 3.5, False), + PowerSample(10.0, 3.3, False), + ] + + assert [PowerMonitor._state_for(sample) for sample in samples] == [ + PowerState.PLUGGED_IN, + PowerState.LOW, + PowerState.CRITICAL, + ] + + +def test_monitor_recovers_after_a_read_failure_and_logs_transitions(): + readers = [ + SequenceReader([OSError("I2C device 0x36 missing")]), + SequenceReader([PowerSample(42.25, 3.72, False)]), + ] + emitted = [] + logged = [] + now = iter([0.0, 1.0]) + monitor = PowerMonitor( + provider=BatteryProvider.GEEKWORM, + on_status=emitted.append, + on_log=logged.append, + reader_factory=lambda: readers.pop(0), + clock=lambda: next(now), + ) + + unavailable = monitor.poll_once() + recovered = monitor.poll_once() + + assert unavailable.state is PowerState.UNAVAILABLE + assert unavailable.provider == "geekworm" + assert "0x36 missing" in unavailable.error + assert recovered.state is PowerState.ON_BATTERY + assert recovered.battery_percent == 42.2 + assert emitted == [unavailable, recovered] + assert logged == [unavailable, recovered] + + +def test_monitor_throttles_unchanged_session_log_samples(): + reader = SequenceReader( + [ + PowerSample(80.0, 4.0, False), + PowerSample(79.0, 3.99, False), + PowerSample(78.0, 3.98, False), + ] + ) + logged = [] + now = iter([0.0, 5.0, 65.0]) + monitor = PowerMonitor( + provider=BatteryProvider.GEEKWORM, + on_status=lambda _status: None, + on_log=logged.append, + reader_factory=lambda: reader, + log_interval_s=60.0, + clock=lambda: next(now), + ) + + monitor.poll_once() + monitor.poll_once() + monitor.poll_once() + + assert [status.battery_percent for status in logged] == [80.0, 78.0] diff --git a/tests/test_geekworm_setup.py b/tests/test_geekworm_setup.py new file mode 100644 index 000000000..c13b7f691 --- /dev/null +++ b/tests/test_geekworm_setup.py @@ -0,0 +1,131 @@ +"""Tests for the Raspberry Pi Geekworm provisioning script.""" + +import hashlib +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SETUP_SCRIPT = PROJECT_ROOT / "scripts" / "battery" / "geekworm" / "setup.sh" +PANEL_PACKAGE = ( + PROJECT_ROOT / "scripts" / "battery" / "packages" / "wfplug-batt_1.3+openflight2_arm64.deb" +) +BATTERY_GUIDE = PROJECT_ROOT / "docs" / "battery" / "README.md" +OPERATOR_GUIDE = PROJECT_ROOT / "docs" / "battery" / "geekworm.md" +MAIN_SETUP_SCRIPT = PROJECT_ROOT / "scripts" / "setup" / "setup.sh" + + +def _run_function(function: str, config_path: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "bash", + "-c", + 'source "$1"; "$2" "$3"', + "geekworm-test", + str(SETUP_SCRIPT), + function, + str(config_path), + ], + check=False, + capture_output=True, + text=True, + ) + + +def test_setup_script_has_valid_bash_syntax(): + subprocess.run(["bash", "-n", SETUP_SCRIPT], check=True) + + +def test_boot_configuration_update_is_idempotent(tmp_path): + config = tmp_path / "config.txt" + config.write_text("[cm5]\nfoo=bar\n", encoding="ascii") + + first = _run_function("update_boot_config", config) + first_content = config.read_text(encoding="ascii") + second = _run_function("update_boot_config", config) + + assert first.returncode == 0, first.stderr + assert second.returncode == 0, second.stderr + assert config.read_text(encoding="ascii") == first_content + assert first_content.count("dtparam=i2c_arm=on") == 1 + assert first_content.count("dtoverlay=i2c-sensor,max17040") == 1 + assert first_content.count("dtoverlay=gpio-charger,gpio=6") == 1 + assert "\n[all]\n# OpenFlight Geekworm" in first_content + + +def test_boot_configuration_accepts_existing_openflight_settings(tmp_path): + config = tmp_path / "config.txt" + original = """\ +dtparam=i2c_arm=on +[all] +dtoverlay=i2c-sensor,max17040 +dtoverlay=gpio-charger,gpio=6,active_low=0,gpio_pull=down,type=mains +""" + config.write_text(original, encoding="ascii") + + result = _run_function("update_boot_config", config) + + assert result.returncode == 0, result.stderr + assert config.read_text(encoding="ascii") == original + + +def test_boot_configuration_rejects_conflicting_charger_overlay(tmp_path): + config = tmp_path / "config.txt" + config.write_text( + "dtoverlay=gpio-charger,gpio=7,active_low=1\n", + encoding="ascii", + ) + + result = _run_function("update_boot_config", config) + + assert result.returncode != 0 + assert "different gpio-charger overlay" in result.stderr + + +def test_eeprom_configuration_replaces_and_deduplicates_power_settings(tmp_path): + config = tmp_path / "eeprom.conf" + config.write_text( + "BOOT_UART=1\nPSU_MAX_CURRENT=3000\nPOWER_OFF_ON_HALT=0\nPSU_MAX_CURRENT=2000\n", + encoding="ascii", + ) + + result = _run_function("update_eeprom_config", config) + content = config.read_text(encoding="ascii") + + assert result.returncode == 0, result.stderr + assert "BOOT_UART=1" in content + assert content.count("PSU_MAX_CURRENT=5000") == 1 + assert content.count("POWER_OFF_ON_HALT=1") == 1 + assert "PSU_MAX_CURRENT=3000" not in content + assert "POWER_OFF_ON_HALT=0" not in content + + +def test_bundled_panel_package_matches_setup_checksum(): + digest = hashlib.sha256(PANEL_PACKAGE.read_bytes()).hexdigest() + + assert digest == "d9bfeb459e13b328a1f77eeb82f5c387aa861d7c8a3337639a74c1288cd23e15" + + +def test_operator_guide_links_models_and_distinguishes_batteries(): + guide = OPERATOR_GUIDE.read_text(encoding="ascii") + + assert "https://geekworm.com/products/x1202" in guide + assert "https://geekworm.com/products/x1206" in guide + assert "Four 3.7V 18650 cells" in guide + assert "Four 3.7V 21700 cells" in guide + assert "X1206 V1.1" in guide + assert "X1206 V2.0" in guide + + +def test_battery_guide_documents_provider_interface_and_cli(): + guide = BATTERY_GUIDE.read_text(encoding="ascii") + + assert "--battery geekworm" in guide + assert "PowerReader" in guide + assert "geekworm.md" in guide + + +def test_main_setup_offers_geekworm_provisioning(): + setup = MAIN_SETUP_SCRIPT.read_text(encoding="utf-8") + + assert 'confirm "Do you have a Geekworm X1202 or X1206 UPS to set up?"' in setup + assert '"$PROJECT_DIR/scripts/battery/geekworm/setup.sh"' in setup diff --git a/tests/test_server.py b/tests/test_server.py index 0cc8eec1f..36b060b6c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -14,6 +14,7 @@ from openflight.kld7.types import KLD7Angle from openflight.launch_monitor import ClubType, Shot from openflight.ops243 import UART_BAUD_COMMANDS +from openflight.power import PowerState from openflight.server import ( MockLaunchMonitor, MockSwingSpeedMonitor, @@ -131,6 +132,36 @@ def test_shot_processing_status_is_forwarded_to_ui(monkeypatch): assert emitted == [("shot_processing", {"state": "capturing"})] +def test_power_status_is_forwarded_to_ui_and_session_log(monkeypatch): + emitted = [] + logged = [] + status = server_module.PowerStatus( + available=True, + provider="geekworm", + state=PowerState.ON_BATTERY, + battery_percent=42.0, + battery_voltage_v=3.72, + external_power=False, + updated_at="2026-08-15T12:00:00+00:00", + ) + monkeypatch.setattr( + server_module.socketio, + "emit", + lambda event, payload: emitted.append((event, payload)), + ) + monkeypatch.setattr( + server_module, + "get_session_logger", + lambda: SimpleNamespace(log_power_status=logged.append), + ) + + server_module._on_power_status(status) + server_module._log_power_status(status) + + assert emitted == [("power_status", status.to_dict())] + assert logged == [status.to_dict()] + + class TestIWR6843ShotIntegration: """TI angle processing must enrich, never suppress, an OPS shot.""" @@ -2766,6 +2797,29 @@ def test_runtime_default_enables_ballistics(self): assert server_module.ballistics_enabled is True +class TestBatteryConfiguration: + """Battery monitoring is explicitly enabled with a supported provider.""" + + def test_cli_accepts_geekworm_provider(self): + parser = argparse.ArgumentParser() + server_module._add_battery_arguments(parser) + + assert parser.parse_args(["--battery", "geekworm"]).battery == "geekworm" + + def test_cli_is_disabled_by_default(self): + parser = argparse.ArgumentParser() + server_module._add_battery_arguments(parser) + + assert parser.parse_args([]).battery is None + + def test_cli_rejects_unknown_provider(self): + parser = argparse.ArgumentParser() + server_module._add_battery_arguments(parser) + + with pytest.raises(SystemExit): + parser.parse_args(["--battery", "unknown"]) + + class TestCarryComputation: """Tests for the ballistic carry path in on_shot_detected.""" diff --git a/tests/test_session_logger.py b/tests/test_session_logger.py index 4a2e18112..02b96d6c9 100644 --- a/tests/test_session_logger.py +++ b/tests/test_session_logger.py @@ -748,3 +748,26 @@ def close(self): assert events[0] == "write" assert "close" in events assert events.index("close") == len(events) - 1 + + +def test_power_status_writes_structured_session_entry(tmp_path): + logger = SessionLogger(log_dir=tmp_path, enabled=True) + logger.start_session(mode="rolling-buffer", trigger_type="sound") + + logger.log_power_status( + { + "available": True, + "state": "on_battery", + "battery_percent": 42.5, + "battery_voltage_v": 3.72, + "external_power": False, + "updated_at": "2026-08-15T12:00:00+00:00", + "error": None, + } + ) + + entry = json.loads(logger.session_path.read_text().strip().split("\n")[-1]) + assert entry["type"] == "power_status" + assert entry["state"] == "on_battery" + assert entry["battery_percent"] == 42.5 + assert entry["external_power"] is False diff --git a/tests/test_start_kiosk.py b/tests/test_start_kiosk.py index b8f51cb45..d4a156a91 100644 --- a/tests/test_start_kiosk.py +++ b/tests/test_start_kiosk.py @@ -41,6 +41,18 @@ def test_no_ballistics_opt_out_is_forwarded(): assert "--no-ballistics" in command_arguments +def test_battery_provider_is_forwarded(): + command_arguments = _dry_run("--battery", "geekworm").stdout.strip().split() + + assert command_arguments[command_arguments.index("--battery") + 1] == "geekworm" + + +def test_removed_geekworm_power_flag_is_not_forwarded(): + command_arguments = _dry_run("--geekworm-power").stdout.strip().split() + + assert "--geekworm-power" not in command_arguments + + def test_existing_ballistics_flag_remains_accepted(): command_arguments = _dry_run("--ballistics").stdout.strip().split() diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8d9fcddb2..d092e521b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -13,6 +13,7 @@ import { ShotList } from './components/ShotList'; import { DebugPanel } from './components/DebugPanel'; import { CameraFeed } from './components/CameraFeed'; import { ConnectionStatus } from './components/ConnectionStatus'; +import { PowerExperience } from './components/PowerStatus'; import { SimStatus } from './components/SimStatus'; import { SimShotBadges } from './components/SimShotBadges'; import { ClubPicker } from './components/ClubPicker'; @@ -256,6 +257,7 @@ function AppContent() { onToggle={() => socketService.toggleCamera()} /> + '); + expect(html).toContain('role="alertdialog"'); + }); + + it('uses stronger language for the 10 percent warning', () => { + const html = renderToString(); + + expect(html).toContain('Battery critically low'); + expect(html).toContain('external power now'); + }); +}); diff --git a/ui/src/components/PowerStatus.tsx b/ui/src/components/PowerStatus.tsx new file mode 100644 index 000000000..ec29602dc --- /dev/null +++ b/ui/src/components/PowerStatus.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useSystemStore } from '../stores/useSystemStore'; +import type { PowerStatus as PowerStatusData } from '../types/power'; +import './PowerStatus.css'; + +type WarningLevel = 'low' | 'critical'; + +function BatteryIcon({ status }: { status: PowerStatusData }) { + const percent = Math.max(0, Math.min(100, status.battery_percent ?? 0)); + + return ( +