On battery with the Cap on, 79% of the gauge reads failed, in groups of up to 110 s. The poll read 3 times in 11 s and then waited 20 s, so it missed the good periods, and after 7 failed polls the gauge went away. - After a failed read, the next read comes after 5.5 s, not 20 s. - The last good reading stays for 10 minutes instead of 7 polls. - A change of the charging flag waits for a second read that agrees, because a garbled read said Charging while the pack discharged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
547 lines
26 KiB
Python
547 lines
26 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Battery gauge tests: the LiPo curve, the one-cell braille glyph, and the poller.
|
|
|
|
The widget is a pure function (percent and flags → a coloured glyph), and the poller runs
|
|
against a fake context. Thus the tests can check the charge estimate, the charging sweep,
|
|
the low-battery blink, and the charging inference with no device and no terminal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from meshterm.core.connection import charging_from_battery_level_status
|
|
from meshterm.services import battery_service
|
|
from meshterm.services.battery_service import (
|
|
_BATTERY_PRESENT_FLOOR_MV,
|
|
POLL_S,
|
|
BatteryService,
|
|
battery_percent,
|
|
)
|
|
from meshterm.ui.widgets import _BATTERY_FLASH_PCT, battery_cell
|
|
|
|
# --- the LiPo state-of-charge curve -------------------------------------------------------
|
|
|
|
|
|
def test_battery_percent_clamps_past_either_end() -> None:
|
|
"""A voltage above the top rung reads 100%. A voltage below the bottom rung reads 0%."""
|
|
assert battery_percent(4300) == 100
|
|
assert battery_percent(4200) == 100
|
|
assert battery_percent(3270) == 0
|
|
assert battery_percent(3000) == 0
|
|
|
|
|
|
def test_battery_percent_interpolates_and_stays_monotonic() -> None:
|
|
"""A rung reads exactly. Between rungs, the function interpolates. More volts is more charge."""
|
|
assert battery_percent(3840) == 50 # an exact rung
|
|
assert 40 < battery_percent(3810) < 45 # between the 3820/45 and 3800/40 rungs
|
|
pcts = [battery_percent(mv) for mv in range(3270, 4201, 10)]
|
|
assert pcts == sorted(pcts)
|
|
|
|
|
|
# --- the one-cell braille glyph -----------------------------------------------------------
|
|
|
|
|
|
def _glyph_style(cell) -> str:
|
|
"""The style of the glyph span of the gauge: the colour of the cell, not of the percent."""
|
|
return str(cell.spans[0].style)
|
|
|
|
|
|
def test_battery_cell_fills_in_eight_half_row_steps() -> None:
|
|
"""One rung for each 12.5%: each dot row lights its left dot first, then its right dot.
|
|
|
|
The rows light from the bottom to the top.
|
|
"""
|
|
assert battery_cell(100).plain[0] == "⣿" # all 8 dots
|
|
assert battery_cell(90).plain[0] == "⣿"
|
|
assert battery_cell(80).plain[0] == chr(0x2800 | 0xF7) # 7 rungs: and the top-left dot
|
|
assert battery_cell(70).plain[0] == chr(0x2800 | 0xF6) # 6 rungs: the bottom three rows
|
|
assert battery_cell(60).plain[0] == chr(0x2800 | 0xE6) # 5 rungs: and a left dot
|
|
assert battery_cell(45).plain[0] == chr(0x2800 | 0xE4) # 4 rungs: the bottom two rows
|
|
assert battery_cell(30).plain[0] == chr(0x2800 | 0xC4) # 3 rungs: and a left dot
|
|
assert battery_cell(20).plain[0] == chr(0x2800 | 0xC0) # 2 rungs: the bottom row
|
|
assert battery_cell(5).plain[0] == chr(0x2800 | 0x40) # 1 rung: the bottom-left dot
|
|
# A pack that is completely flat still lights that one dot, not an empty cell.
|
|
assert battery_cell(0).plain[0] == chr(0x2800 | 0x40)
|
|
|
|
|
|
def test_battery_cell_steps_on_every_eighth_of_the_pack() -> None:
|
|
"""The fill is monotonic and has eight different levels, 12.5% apart."""
|
|
fills = [battery_cell(pct).plain[0] for pct in range(101)]
|
|
assert len(set(fills)) == 8
|
|
# Each band starts at its lower bound: 12.5% is the second rung, 87.5% the eighth.
|
|
assert fills[12] != fills[13] and fills[13] == fills[24] != fills[25]
|
|
assert fills[87] != fills[88] and fills[88] == fills[100]
|
|
|
|
|
|
def test_battery_cell_shows_the_true_percent() -> None:
|
|
"""The number next to the glyph is the real charge."""
|
|
assert battery_cell(42).plain == chr(0x2800 | 0xE4) + " 42%"
|
|
|
|
|
|
def test_battery_cell_drops_the_sign_at_full_so_the_header_stops_moving() -> None:
|
|
"""A full pack reads ``100``, which is three cells, the same as ``99%``.
|
|
|
|
A pack that is full changes between these two readings. The charger stops at full, the
|
|
pack settles to 99, the charger starts again, and the change repeats at each poll while
|
|
the pack is plugged in. The gauge is fixed to the right edge of the header, and the
|
|
pulse takes the space that is left. Thus a fourth cell at the top of the range took a
|
|
cell from the sparkline and rescaled the whole activity history, twice each minute, on a
|
|
device that did not move. The sign can be inferred, so the gauge drops it. This costs
|
|
nothing and corrects the problem.
|
|
"""
|
|
assert battery_cell(100).plain.endswith(" 100") # no sign: a full block says it
|
|
assert battery_cell(99).cell_len == battery_cell(100).cell_len
|
|
# All other readings keep their sign. The charging sweep and the alarm never change it.
|
|
assert battery_cell(99).plain.endswith(" 99%") and battery_cell(9).plain.endswith(" 9%")
|
|
assert {
|
|
battery_cell(pct, charging=True, frame=f).plain.endswith("%")
|
|
for pct in (5, 99)
|
|
for f in range(6)
|
|
} == {True}
|
|
|
|
|
|
def test_battery_cell_colours_by_band_while_the_dots_carry_the_detail() -> None:
|
|
"""Three wide bands (above half, above a quarter, and below), each a block of its own hue."""
|
|
assert _glyph_style(battery_cell(100)) == "batt.full" # light green on green
|
|
assert _glyph_style(battery_cell(50)) == "batt.full"
|
|
assert _glyph_style(battery_cell(49)) == "batt.mid" # yellow on brown
|
|
assert _glyph_style(battery_cell(25)) == "batt.mid"
|
|
assert _glyph_style(battery_cell(24)) == "batt.low" # light red on red
|
|
assert _glyph_style(battery_cell(7)) == "batt.low"
|
|
# The band stays the same across all the rungs of the band. Thus only the fill moves in
|
|
# the band. (Below 6.25% the alarm takes the cell, which the next test covers.)
|
|
turns = [
|
|
p
|
|
for p in range(8, 101)
|
|
if _glyph_style(battery_cell(p)) != _glyph_style(battery_cell(p - 1))
|
|
]
|
|
assert turns == [25, 50]
|
|
assert len({battery_cell(p).plain[0] for p in range(25, 50)}) == 2 # two rungs per band
|
|
|
|
|
|
def test_battery_cell_alarm_drops_the_block_on_alternate_frames() -> None:
|
|
"""Under 6.25% the cell alternates black on red with light red on the bare page."""
|
|
# Half a rung: a rung is an eighth of the pack, so the alarm line is 6.25%.
|
|
assert _BATTERY_FLASH_PCT == 6.25
|
|
assert _glyph_style(battery_cell(6, frame=0)) == "batt.flash" # black dots on red
|
|
assert _glyph_style(battery_cell(6, frame=1)) == "batt.flash.off" # light red, no background
|
|
assert _glyph_style(battery_cell(0, frame=1)) == "batt.flash.off"
|
|
# Each value above the line keeps its band, also when the value is low.
|
|
assert {_glyph_style(battery_cell(7, frame=f)) for f in range(4)} == {"batt.low"}
|
|
assert {_glyph_style(battery_cell(40, frame=f)) for f in range(4)} == {"batt.mid"}
|
|
|
|
|
|
def test_battery_cell_charging_sweeps_bottom_to_full_holding_the_percent() -> None:
|
|
"""Charging animates the fill from empty to full in a loop. The % stays the true charge."""
|
|
fills = [battery_cell(20, charging=True, frame=f).plain[0] for f in range(6)]
|
|
assert fills[0] == chr(0x2800) # empty
|
|
assert fills[4] == "⣿" # full
|
|
assert fills[5] == fills[0] # the loop starts again
|
|
assert battery_cell(20, charging=True, frame=4).plain.endswith(" 20%")
|
|
|
|
|
|
def test_battery_cell_charging_keeps_the_bands_colour_while_the_fill_sweeps() -> None:
|
|
"""The sweep is an animation, not a reading. The colour still shows the real charge."""
|
|
charging = {_glyph_style(battery_cell(20, charging=True, frame=f)) for f in range(5)}
|
|
assert charging == {"batt.low"}
|
|
charging = {_glyph_style(battery_cell(40, charging=True, frame=f)) for f in range(5)}
|
|
assert charging == {"batt.mid"}
|
|
charging = {_glyph_style(battery_cell(90, charging=True, frame=f)) for f in range(5)}
|
|
assert charging == {"batt.full"}
|
|
|
|
|
|
def test_battery_cell_charging_never_alarms_however_empty_the_pack_is() -> None:
|
|
"""A pack that takes charge shows it with the sweep. The alarm would conflict with the sweep."""
|
|
for pct in (0, 3, 6, 10, 20):
|
|
styles = {_glyph_style(battery_cell(pct, charging=True, frame=f)) for f in range(10)}
|
|
assert styles == {"batt.low"}
|
|
|
|
|
|
def test_battery_cell_animates_off_the_frame_counter_alone() -> None:
|
|
"""Both live states run on each platform. There is no effects flag, only the paint rate."""
|
|
fills = [battery_cell(20, charging=True, frame=f).plain[0] for f in range(5)]
|
|
assert len(set(fills)) == 5 # the sweep climbs, and does not hold one animation step
|
|
assert len({_glyph_style(battery_cell(3, frame=f)) for f in range(2)}) == 2 # also alarms
|
|
|
|
|
|
def test_battery_cell_at_full_rests_full_however_the_charging_flag_reads() -> None:
|
|
"""A full pack draws as not charging, so a plugged-in charger stops the sweep."""
|
|
assert battery_cell(100, charging=True, frame=0).plain == "⣿ 100"
|
|
assert {battery_cell(100, charging=True, frame=f).plain for f in range(6)} == {"⣿ 100"}
|
|
# At one percent below full, the pack still fills, and the sweep still runs.
|
|
assert battery_cell(99, charging=True, frame=0).plain[0] == chr(0x2800)
|
|
|
|
|
|
# --- the poller ---------------------------------------------------------------------------
|
|
|
|
|
|
def _service(levels, hw_charging=None) -> BatteryService:
|
|
"""A ``BatteryService`` over a fake context whose device answers ``levels`` in turn.
|
|
|
|
``hw_charging`` is the value that the charging flag of the device firmware reports at
|
|
each poll. ``None`` (the default, and the value of each real MeshCore device) means
|
|
"no flag", so the poller infers the state.
|
|
"""
|
|
seq = list(levels)
|
|
|
|
async def get_battery() -> dict:
|
|
return {"level": seq.pop(0)} if seq else {}
|
|
|
|
async def get_hw_charging():
|
|
return hw_charging
|
|
|
|
async def device() -> SimpleNamespace:
|
|
return SimpleNamespace(get_battery=get_battery, get_hw_charging=get_hw_charging)
|
|
|
|
ctx = SimpleNamespace(
|
|
is_connected=True,
|
|
device=device,
|
|
log=SimpleNamespace(info=lambda *a, **k: None, debug=lambda *a, **k: None),
|
|
)
|
|
return BatteryService(ctx)
|
|
|
|
|
|
def test_battery_poller_reads_and_estimates() -> None:
|
|
"""One pass reads the pack and caches a charge estimate. With one sample, charging is off."""
|
|
svc = _service([3840]) # 50%
|
|
asyncio.run(svc._poll())
|
|
reading = svc.reading()
|
|
assert reading is not None
|
|
assert reading.millivolts == 3840 and reading.percent == 50 and reading.charging is False
|
|
|
|
|
|
def test_battery_poller_hides_a_device_with_no_pack() -> None:
|
|
"""An empty level, or a level below the floor, reads as no battery. The header shows nothing."""
|
|
svc = _service([0])
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading() is None
|
|
svc = _service([_BATTERY_PRESENT_FLOOR_MV - 1])
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading() is None
|
|
|
|
|
|
def _history_of(now: float, mvs: list[int]) -> list[tuple[float, int]]:
|
|
"""``(time, mv)`` samples one poll apart, oldest first, with the last sample at ``now``."""
|
|
n = len(mvs)
|
|
return [(now - (n - 1 - i) * POLL_S, mv) for i, mv in enumerate(mvs)]
|
|
|
|
|
|
def test_battery_poller_calls_charging_only_on_a_strong_sustained_rise() -> None:
|
|
"""A large, sustained climb reads as charging. A gentle climb only holds a set verdict."""
|
|
svc = _service([])
|
|
now = 1000.0
|
|
|
|
# A clear, sustained rise from a cold (not charging) start crosses the ON threshold.
|
|
svc._history.extend(_history_of(now, [3700, 3712, 3724, 3736, 3748, 3760, 3772, 3784]))
|
|
assert svc._charging(now) is True
|
|
|
|
# A gentle climb (above the hold floor, below the start floor) does not start a verdict…
|
|
svc._reading = SimpleNamespace(charging=False)
|
|
svc._history.clear()
|
|
svc._history.extend(_history_of(now, [3900, 3902, 3904, 3915, 3917, 3919]))
|
|
assert svc._charging(now) is False
|
|
# …but it holds a verdict that is already set. Thus a real charge does not flicker on
|
|
# the jitter of a poll.
|
|
svc._reading = SimpleNamespace(charging=True)
|
|
assert svc._charging(now) is True
|
|
|
|
|
|
def test_battery_poller_charging_ignores_load_sag_and_flat_or_falling_packs() -> None:
|
|
"""A short TX sag, a flat pack, and a falling pack all read as not charging."""
|
|
svc = _service([])
|
|
now = 1000.0
|
|
|
|
# One deep TX sag at the start of the window would make a raw difference between the
|
|
# first and last sample show a +60 mV "rise". The median of each half removes the spike,
|
|
# so the check does not report a rise.
|
|
svc._reading = SimpleNamespace(charging=False)
|
|
svc._history.extend(_history_of(now, [3740, 3802, 3798, 3801, 3800, 3799, 3802, 3800]))
|
|
assert svc._charging(now) is False
|
|
|
|
# A pack that stays flat (full, or unplugged) removes a charging verdict.
|
|
svc._reading = SimpleNamespace(charging=True)
|
|
svc._history.clear()
|
|
svc._history.extend(_history_of(now, [3800, 3801, 3800, 3799, 3800, 3801]))
|
|
assert svc._charging(now) is False
|
|
|
|
# A clear discharge never reads as charging.
|
|
svc._reading = SimpleNamespace(charging=True)
|
|
svc._history.clear()
|
|
svc._history.extend(_history_of(now, [3900, 3890, 3880, 3870, 3860, 3850]))
|
|
assert svc._charging(now) is False
|
|
|
|
# Too little history to cover the window means not charging, for any last verdict.
|
|
svc._reading = SimpleNamespace(charging=True)
|
|
svc._history.clear()
|
|
svc._history.extend(_history_of(now, [3800, 3900]))
|
|
assert svc._charging(now) is False
|
|
|
|
|
|
# --- the host pack (the PicoCalc's own power_supply) ---------------------------------------
|
|
|
|
|
|
def _host_supply(tmp_path, monkeypatch, name: str = "picocalc", **files: str):
|
|
"""Make a fake ``power_supply`` class with one battery, and point the poller at it."""
|
|
supply = tmp_path / name
|
|
supply.mkdir(exist_ok=True)
|
|
(supply / "type").write_text("Battery\n")
|
|
for key, value in files.items():
|
|
(supply / key).write_text(f"{value}\n")
|
|
monkeypatch.setattr(battery_service, "_POWER_SUPPLIES", tmp_path)
|
|
return supply
|
|
|
|
|
|
def test_host_pack_reads_the_drivers_own_percent_and_charging_flag(tmp_path, monkeypatch) -> None:
|
|
"""On the handheld, both numbers come from the handheld: no LiPo curve, no voltage trend."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
svc = _service([])
|
|
# A discharging pack, as the driver reports it (this pack has no voltage_now).
|
|
_host_supply(tmp_path, monkeypatch, capacity="76", status="Discharging")
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().percent == 76 and svc.reading().charging is False
|
|
# Plugged in: the flag changes on the word of the driver only, with no history for a trend.
|
|
# The change shows when a second read agrees, one short pause later.
|
|
_host_supply(tmp_path, monkeypatch, capacity="77", status="Charging")
|
|
assert asyncio.run(svc._poll()) == battery_service._HOST_RETRY_S
|
|
assert svc.reading().charging is False
|
|
assert asyncio.run(svc._poll()) == POLL_S
|
|
assert svc.reading().charging is True
|
|
assert not svc._history # the trend code never runs on this path
|
|
# A full pack does not take charge, also when a charger is plugged in.
|
|
svc = _service([])
|
|
_host_supply(tmp_path, monkeypatch, capacity="100", status="Full")
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().percent == 100 and svc.reading().charging is False
|
|
|
|
|
|
def test_host_pack_absent_when_the_supply_cant_be_read(tmp_path, monkeypatch) -> None:
|
|
"""No driver, or a driver that MeshTerm cannot read, means no battery and no gauge."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
svc = _service([])
|
|
monkeypatch.setattr(battery_service, "_POWER_SUPPLIES", tmp_path / "nothing-here")
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading() is None
|
|
# A supply that answers with junk is also absent. The header never draws a false gauge.
|
|
_host_supply(tmp_path, monkeypatch, capacity="", status="Charging")
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading() is None
|
|
|
|
|
|
def test_the_host_pack_is_found_by_what_it_is(tmp_path, monkeypatch) -> None:
|
|
"""The gauge of the Cardputer Zero has another name. A peripheral battery is not the host's."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
mouse = _host_supply(tmp_path, monkeypatch, name="hid-mouse-battery", capacity="5")
|
|
(mouse / "scope").write_text("Device\n")
|
|
charger = tmp_path / "AC"
|
|
charger.mkdir()
|
|
(charger / "type").write_text("Mains\n")
|
|
_host_supply(tmp_path, monkeypatch, name="bq27220-0", capacity="84", status="Charging")
|
|
svc = _service([])
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().percent == 84 and svc.reading().charging is True
|
|
|
|
|
|
def test_a_failed_read_keeps_the_last_reading_for_a_while(tmp_path, monkeypatch) -> None:
|
|
"""A BQ27220 read sometimes fails on the I2C of the CM0. The gauge must not disappear."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
supply = _host_supply(tmp_path, monkeypatch, capacity="84", status="Discharging")
|
|
svc = _service([])
|
|
asyncio.run(svc._poll())
|
|
(supply / "capacity").unlink() # the read of the driver now fails
|
|
# More failed reads than the old limit of 7 polls. Each one asks again after a short
|
|
# pause, and the reading stays while it is recent.
|
|
for _ in range(20):
|
|
assert asyncio.run(svc._poll()) == battery_service._HOST_RETRY_S
|
|
assert svc.reading().percent == 84
|
|
svc._host_read_at -= battery_service._HOST_STALE_S + 1 # the last good read is now too old
|
|
asyncio.run(svc._poll()) # the pack is gone, not only difficult to read
|
|
assert svc.reading() is None
|
|
(supply / "capacity").write_text("83\n")
|
|
assert asyncio.run(svc._poll()) == POLL_S
|
|
assert svc.reading().percent == 83
|
|
|
|
|
|
def _flaky_reads(monkeypatch, failures: dict[str, int]) -> None:
|
|
"""Make each named sysfs file fail its next N reads with an I/O error, as on a noisy bus."""
|
|
real = battery_service.Path.read_text
|
|
left = dict(failures)
|
|
|
|
def read_text(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003, ANN202
|
|
if left.get(self.name, 0) > 0:
|
|
left[self.name] -= 1
|
|
raise OSError(121, "Remote I/O error")
|
|
return real(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(battery_service.Path, "read_text", read_text)
|
|
|
|
|
|
def test_an_io_error_is_asked_again_not_believed(tmp_path, monkeypatch) -> None:
|
|
"""When the Cap has power, a gauge read often fails, and a read a short time later answers."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
_host_supply(tmp_path, monkeypatch, capacity="84", status="Charging")
|
|
_flaky_reads(monkeypatch, {"capacity": 2})
|
|
svc = _service([])
|
|
assert asyncio.run(svc._poll()) == battery_service._HOST_RETRY_S
|
|
assert asyncio.run(svc._poll()) == battery_service._HOST_RETRY_S
|
|
assert svc.reading() is None
|
|
assert asyncio.run(svc._poll()) == POLL_S
|
|
assert svc.reading().percent == 84 and svc.reading().charging is True
|
|
|
|
|
|
def _scripted_capacity(monkeypatch, answers: list[str]) -> None:
|
|
"""Make ``capacity`` answer each of ``answers`` in turn, as a bus that corrupts data does."""
|
|
script = iter(answers)
|
|
real = battery_service.Path.read_text
|
|
|
|
def read_text(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003, ANN202
|
|
return f"{next(script)}\n" if self.name == "capacity" else real(self, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(battery_service.Path, "read_text", read_text)
|
|
|
|
|
|
def test_a_garbled_percent_is_never_shown(tmp_path, monkeypatch) -> None:
|
|
"""56414 is not a percent. The service asks again, after the cache of the driver releases it."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
_host_supply(tmp_path, monkeypatch, capacity="94", status="Discharging")
|
|
_scripted_capacity(monkeypatch, ["56414", "94"])
|
|
svc = _service([])
|
|
assert asyncio.run(svc._poll()) == battery_service._HOST_RETRY_S
|
|
assert svc.reading() is None
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().percent == 94
|
|
|
|
|
|
def test_a_big_step_waits_for_the_next_read(tmp_path, monkeypatch) -> None:
|
|
"""A corrupted read can give a value from 0 to 100. A pack cannot fall 57 points in one poll."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
_host_supply(tmp_path, monkeypatch, capacity="94", status="Discharging")
|
|
_scripted_capacity(monkeypatch, ["94", "37", "93", "60", "60"])
|
|
svc = _service([])
|
|
shown, delays = [], []
|
|
for _ in range(5):
|
|
delays.append(asyncio.run(svc._poll()))
|
|
shown.append(svc.reading().percent)
|
|
# The service holds 37 and never confirms it. It accepts a small step at once. The value
|
|
# 60 two times is real. Each held step asks for its second read after a short pause.
|
|
assert shown == [94, 94, 93, 93, 60]
|
|
retry = battery_service._HOST_RETRY_S
|
|
assert delays == [POLL_S, retry, POLL_S, retry, POLL_S]
|
|
|
|
|
|
def test_a_charging_flag_waits_for_a_second_read(tmp_path, monkeypatch) -> None:
|
|
"""A garbled read can say ``Charging`` while the pack discharges. The gauge must not sweep."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
supply = _host_supply(tmp_path, monkeypatch, capacity="84", status="Discharging")
|
|
svc = _service([])
|
|
asyncio.run(svc._poll())
|
|
# One false "Charging" between two true reads never reaches the gauge.
|
|
(supply / "status").write_text("Charging\n")
|
|
assert asyncio.run(svc._poll()) == battery_service._HOST_RETRY_S
|
|
assert svc.reading().charging is False
|
|
(supply / "status").write_text("Discharging\n")
|
|
assert asyncio.run(svc._poll()) == POLL_S
|
|
assert svc.reading().charging is False
|
|
# Two reads that agree are a real change, in both directions.
|
|
(supply / "status").write_text("Charging\n")
|
|
asyncio.run(svc._poll())
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().charging is True
|
|
(supply / "status").write_text("Discharging\n")
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().charging is True
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().charging is False
|
|
|
|
|
|
def test_the_poll_loop_waits_as_long_as_each_pass_asks(monkeypatch) -> None:
|
|
"""A pass returns the pause before the next pass. A pass that raises waits a full poll."""
|
|
svc = _service([])
|
|
answers = iter([battery_service._HOST_RETRY_S, RuntimeError("bus"), POLL_S])
|
|
|
|
async def poll() -> float:
|
|
answer = next(answers)
|
|
if isinstance(answer, Exception):
|
|
raise answer
|
|
return answer
|
|
|
|
slept = []
|
|
|
|
async def sleep(seconds: float) -> None:
|
|
slept.append(seconds)
|
|
if len(slept) == 3:
|
|
raise asyncio.CancelledError
|
|
|
|
svc._poll = poll
|
|
monkeypatch.setattr(battery_service.asyncio, "sleep", sleep)
|
|
with pytest.raises(asyncio.CancelledError):
|
|
asyncio.run(svc._run())
|
|
assert slept == [battery_service._HOST_RETRY_S, POLL_S, POLL_S]
|
|
|
|
|
|
def test_a_status_that_will_not_read_keeps_the_last_verdict(tmp_path, monkeypatch) -> None:
|
|
"""The percent is the reading. If the bus loses a flag, the service keeps the old flag."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
supply = _host_supply(tmp_path, monkeypatch, capacity="84", status="Charging")
|
|
svc = _service([])
|
|
asyncio.run(svc._poll())
|
|
(supply / "capacity").write_text("85\n")
|
|
_flaky_reads(monkeypatch, {"status": 1})
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().percent == 85 and svc.reading().charging is True
|
|
|
|
|
|
# --- the standard BLE charging flag (GATT Battery Level Status, 0x2BED) --------------------
|
|
|
|
|
|
def _level_status(charge_state: int, *, flags: int = 0, extra: bytes = b"") -> bytes:
|
|
"""A Battery Level Status value: a flags byte, the Power State word, then an optional tail."""
|
|
power_state = (charge_state & 0b11) << 5
|
|
return bytes([flags]) + power_state.to_bytes(2, "little") + extra
|
|
|
|
|
|
def test_charging_flag_decodes_the_charge_state_field() -> None:
|
|
"""The 2-bit Charge State enum gives charging, not charging, or unknown, as the GSS says."""
|
|
assert charging_from_battery_level_status(_level_status(1)) is True # charging
|
|
assert charging_from_battery_level_status(_level_status(2)) is False # discharging (active)
|
|
assert charging_from_battery_level_status(_level_status(3)) is False # discharging (inactive)
|
|
assert charging_from_battery_level_status(_level_status(0)) is None # unknown, so infer
|
|
|
|
|
|
def test_charging_flag_reads_only_the_power_state_word() -> None:
|
|
"""Other Power State bits and the optional identifier and level tail do not change it."""
|
|
# Battery present, wired external power, and charging, with identifier and level bytes
|
|
# at the end.
|
|
value = _level_status(1, flags=0b011, extra=b"\xab\xcd\x50")
|
|
assert charging_from_battery_level_status(value) is True
|
|
# A short value (no room for the Power State word) is unknown, and never a guess.
|
|
assert charging_from_battery_level_status(b"\x00") is None
|
|
assert charging_from_battery_level_status(b"") is None
|
|
|
|
|
|
def test_battery_poller_prefers_the_hardware_charging_flag_over_inference() -> None:
|
|
"""A firmware charging flag wins over the guess from the voltage trend, in both directions."""
|
|
now = time.monotonic()
|
|
rising = [3700, 3712, 3724, 3736, 3748, 3760, 3772, 3784]
|
|
|
|
# Baseline: with no hardware flag, the service infers charging from the rising trend.
|
|
svc = _service([3900], hw_charging=None)
|
|
svc._history.extend(_history_of(now, rising))
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().charging is True
|
|
|
|
# The same rising trend, but the hardware reports not charging: the flag overrides the guess.
|
|
svc = _service([3900], hw_charging=False)
|
|
svc._history.extend(_history_of(now, rising))
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().charging is False
|
|
|
|
# Also, a hardware "charging" wins with no trend, where inference would say not charging.
|
|
svc = _service([3900], hw_charging=True)
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().charging is True
|