From b52fc3c87793f6ab894ab58f52e8391caff55c1a Mon Sep 17 00:00:00 2001 From: John Pacino Date: Sat, 15 Aug 2026 17:12:20 -0400 Subject: [PATCH 01/10] Add Geekworm battery power display --- README.md | 4 + docs/geekworm-power.md | 59 +++++++++ scripts/start-kiosk.sh | 9 ++ src/openflight/power/__init__.py | 13 ++ src/openflight/power/geekworm.py | 123 ++++++++++++++++++ src/openflight/power/models.py | 45 +++++++ src/openflight/power/service.py | 158 +++++++++++++++++++++++ src/openflight/server.py | 39 ++++++ src/openflight/session_logger.py | 6 + tests/test_geekworm_power.py | 155 +++++++++++++++++++++++ tests/test_server.py | 30 +++++ tests/test_session_logger.py | 23 ++++ tests/test_start_kiosk.py | 6 + ui/src/App.tsx | 2 + ui/src/components/PowerStatus.css | 169 +++++++++++++++++++++++++ ui/src/components/PowerStatus.test.tsx | 64 ++++++++++ ui/src/components/PowerStatus.tsx | 90 +++++++++++++ ui/src/services/socketService.ts | 5 + ui/src/stores/useSystemStore.ts | 5 + ui/src/types/power.ts | 11 ++ 20 files changed, 1016 insertions(+) create mode 100644 docs/geekworm-power.md create mode 100644 src/openflight/power/__init__.py create mode 100644 src/openflight/power/geekworm.py create mode 100644 src/openflight/power/models.py create mode 100644 src/openflight/power/service.py create mode 100644 tests/test_geekworm_power.py create mode 100644 ui/src/components/PowerStatus.css create mode 100644 ui/src/components/PowerStatus.test.tsx create mode 100644 ui/src/components/PowerStatus.tsx create mode 100644 ui/src/types/power.ts diff --git a/README.md b/README.md index 71bec9afb..2eb835828 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 --geekworm-power ``` The IWR6843 example values are not universal. Measure the geometry from the @@ -303,6 +306,7 @@ 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 +- **[Geekworm Power Display](docs/geekworm-power.md)** — X1202/X1206 battery monitoring 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/geekworm-power.md b/docs/geekworm-power.md new file mode 100644 index 000000000..8b99785ec --- /dev/null +++ b/docs/geekworm-power.md @@ -0,0 +1,59 @@ +# Geekworm Power Display + +OpenFlight can show battery and external-power status for the Geekworm X1202 +and X1206 UPS boards. Monitoring is opt-in and uses the same interface on both +models: + +- MAX17043-compatible fuel gauge at I2C address `0x36` +- GPIO6 high when an external power adapter is present + +OpenFlight only reads these signals. It does not control charging through +GPIO16 and does not automatically shut down at a low battery level. + +## Hardware setup + +Install the Raspberry Pi 5 on the UPS pogo pins and connect all external power +and powered peripherals to the Geekworm board. Do not power the Pi through its +own USB-C port while using the UPS. + +Enable I2C with `sudo raspi-config`, then reboot. To verify the fuel gauge: + +```bash +sudo i2cdetect -y 1 +``` + +The scan should show a device at `36`. If it does not, power down and check the +pogo-pin contact for GPIO2 and GPIO3 before starting OpenFlight. + +## Start OpenFlight + +```bash +scripts/start-kiosk.sh --geekworm-power +``` + +With the flag enabled, the header shows the battery percentage and whether +external power is connected. OpenFlight displays dismissible warnings while +discharging at 20% and 10%. Plugging in external power, recovering above the +threshold, or restarting the UI starts a new warning episode. + +If I2C or GPIO telemetry fails, OpenFlight continues running and shows an +unavailable power indicator. The monitor retries automatically every five +seconds. + +## Session logs + +The session JSONL includes `power_status` entries on startup, power-state +changes, warning-threshold changes, telemetry failures or recovery, and at +least once per minute while the state is unchanged. Each entry includes: + +- `state` +- `battery_percent` +- `battery_voltage_v` +- `external_power` +- `available` +- `error` +- `updated_at` + +When `--geekworm-power` is absent, OpenFlight does not access the UPS hardware +or show a power indicator. A Pi powered directly from its official adapter has +no battery telemetry, so the power display remains hidden in that setup. diff --git a/scripts/start-kiosk.sh b/scripts/start-kiosk.sh index 70f943317..67dea7d8f 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 +GEEKWORM_POWER=false SWING_SPEED=false SWING_SPEED_THRESHOLD="" SWING_SPEED_MIN_READINGS="" @@ -106,6 +107,10 @@ while [[ $# -gt 0 ]]; do RADAR_LOG=true shift ;; + --geekworm-power) + GEEKWORM_POWER=true + shift + ;; --debug|-d) DEBUG_MODE=true shift @@ -539,6 +544,10 @@ if [ "$NO_CAMERA" = true ]; then SERVER_CMD="$SERVER_CMD --no-camera" fi +if [ "$GEEKWORM_POWER" = true ]; then + SERVER_CMD="$SERVER_CMD --geekworm-power" +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..20c52e9c8 --- /dev/null +++ b/src/openflight/power/__init__.py @@ -0,0 +1,13 @@ +"""Power monitoring for supported OpenFlight installations.""" + +from .geekworm import GeekwormPowerReader +from .models import PowerSample, PowerState, PowerStatus +from .service import PowerMonitor + +__all__ = [ + "GeekwormPowerReader", + "PowerMonitor", + "PowerSample", + "PowerState", + "PowerStatus", +] diff --git a/src/openflight/power/geekworm.py b/src/openflight/power/geekworm.py new file mode 100644 index 000000000..f28fd2b6b --- /dev/null +++ b/src/openflight/power/geekworm.py @@ -0,0 +1,123 @@ +"""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 electrically high when the adapter is present. gpiozero + # otherwise inverts ``value`` for pull-up inputs by default. + ac_input = DigitalInputDevice( + self.AC_DETECT_PIN, + pull_up=True, + active_state=True, + ) + 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/models.py b/src/openflight/power/models.py new file mode 100644 index 000000000..506d38d63 --- /dev/null +++ b/src/openflight/power/models.py @@ -0,0 +1,45 @@ +"""Shared Geekworm 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: + """One validated reading from the Geekworm UPS.""" + + 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 + 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/service.py b/src/openflight/power/service.py new file mode 100644 index 000000000..ba17118ed --- /dev/null +++ b/src/openflight/power/service.py @@ -0,0 +1,158 @@ +"""Background Geekworm power 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, Protocol + +from .geekworm import GeekwormPowerReader +from .models import PowerSample, PowerState, PowerStatus + +logger = logging.getLogger(__name__) + + +class PowerReader(Protocol): # pylint: disable=unnecessary-ellipsis + """Hardware reader contract used by the background monitor.""" + + def read(self) -> PowerSample: + """Read one UPS sample.""" + ... # pylint: disable=unnecessary-ellipsis + + def close(self) -> None: + """Release hardware resources.""" + ... # pylint: disable=unnecessary-ellipsis + + +class PowerMonitor: + """Poll Geekworm telemetry and publish stable application-level status.""" + + def __init__( # pylint: disable=too-many-arguments + self, + *, + on_status: Callable[[PowerStatus], None], + on_log: Callable[[PowerStatus], None] | None = None, + reader_factory: Callable[[], PowerReader] = GeekwormPowerReader, + poll_interval_s: float = 5.0, + log_interval_s: float = 60.0, + clock: Callable[[], float] = time.monotonic, + ): + self.on_status = on_status + self.on_log = on_log + self.reader_factory = reader_factory + 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, + 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, + 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 Geekworm 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] Geekworm telemetry unavailable: %s", 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="geekworm-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..56ecaa8d6 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -30,6 +30,7 @@ SpeedReading, set_show_raw_readings, ) +from .power import 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 +geekworm_power_enabled: bool = False 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("Geekworm power monitor stop", power_monitor.stop) _run_shutdown_step("camera thread stop", stop_camera_thread) if camera: @@ -859,6 +864,7 @@ def _session_start_config() -> dict: } config["iwr6843"] = dict(iwr6843_runtime_config) config["inclinometer"] = dict(inclinometer_runtime_config) + config["power"] = {"geekworm_enabled": geekworm_power_enabled} return config @@ -1658,11 +1664,33 @@ def _emit_sim_snapshot() -> None: ) +def _on_power_status(status: PowerStatus) -> None: + """Publish one Geekworm power reading to connected UI clients.""" + socketio.emit("power_status", status.to_dict()) + + +def _log_power_status(status: PowerStatus) -> None: + """Write throttled Geekworm 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() -> None: + """Start optional Geekworm monitoring without blocking server startup.""" + global power_monitor # pylint: disable=global-statement + power_monitor = PowerMonitor(on_status=_on_power_status, on_log=_log_power_status) + power_monitor.start() + logger.info("[POWER] Geekworm X1202/X1206 monitoring enabled") + + @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( @@ -3639,6 +3667,11 @@ def main(): "--log-dir", help="Directory for session logs (default: ~/openflight_sessions)" ) parser.add_argument("--no-logging", action="store_true", help="Disable session logging") + parser.add_argument( + "--geekworm-power", + action="store_true", + help="Show battery and external-power status for Geekworm X1202/X1206 UPS boards", + ) parser.add_argument( "--sim", action="store_true", @@ -4031,6 +4064,7 @@ def main(): global experimental_kld7_raw_radc_logging global active_kld7_radc_tuning global ballistics_enabled + global geekworm_power_enabled 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 +4080,7 @@ def main(): global calculated_spin_enabled calculated_spin_enabled = args.calculated_spin ballistics_enabled = args.ballistics + geekworm_power_enabled = args.geekworm_power kld7_radc_tuning_kwargs = _kld7_radc_tuning_kwargs(args) active_kld7_radc_tuning = dict(kld7_radc_tuning_kwargs) @@ -4237,6 +4272,10 @@ def main(): ops_baud=args.ops_baud, ) + if geekworm_power_enabled: + start_power_monitor() + print("Geekworm power monitoring: ENABLED") + # 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..ec501eb92 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 Geekworm UPS 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..68f17bc65 --- /dev/null +++ b/tests/test_geekworm_power.py @@ -0,0 +1,155 @@ +"""Tests for X1202/X1206 battery and adapter telemetry.""" + +import sys +from types import ModuleType + +from openflight import gpio_factory +from openflight.power import GeekwormPowerReader, PowerMonitor, PowerSample, PowerState + + +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_pull_up(monkeypatch): + created = {} + + class SpyInput(FakeInput): + def __init__(self, pin, **kwargs): + 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": True, "active_state": True} + assert reader.read().external_power is True + + +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( + 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 "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( + 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_server.py b/tests/test_server.py index 0cc8eec1f..b680898a3 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,35 @@ 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, + 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.""" 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..06f332d97 100644 --- a/tests/test_start_kiosk.py +++ b/tests/test_start_kiosk.py @@ -41,6 +41,12 @@ def test_no_ballistics_opt_out_is_forwarded(): assert "--no-ballistics" in command_arguments +def test_geekworm_power_flag_is_forwarded(): + command_arguments = _dry_run("--geekworm-power").stdout.strip().split() + + assert "--geekworm-power" 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 ( +