Each test docstring states the behaviour that the test proves, in the simple present. No assertion message, test id, or other string literal changed. Four statements now agree with the code: the Trace highlight stays on the last row, a menu count, and two others. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
492 lines
23 KiB
Python
492 lines
23 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) ---------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_retry_pause(monkeypatch) -> None:
|
|
"""On the handheld, the retries for the host pack wait for a kernel cache. Here, they do not."""
|
|
monkeypatch.setattr(battery_service, "_HOST_RETRY_S", 0)
|
|
|
|
|
|
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.
|
|
_host_supply(tmp_path, monkeypatch, capacity="77", status="Charging")
|
|
asyncio.run(svc._poll())
|
|
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
|
|
for _ in range(battery_service._HOST_MISSES_KEPT):
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().percent == 84
|
|
asyncio.run(svc._poll()) # one miss too many: the pack is gone, not only slow
|
|
assert svc.reading() is None
|
|
(supply / "capacity").write_text("83\n")
|
|
asyncio.run(svc._poll())
|
|
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)
|
|
monkeypatch.setattr(battery_service, "_HOST_RETRY_S", 0)
|
|
|
|
|
|
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 later read answers."""
|
|
monkeypatch.setattr(battery_service, "get_platform", lambda: SimpleNamespace(battery="host"))
|
|
_host_supply(tmp_path, monkeypatch, capacity="84", status="Charging")
|
|
_flaky_reads(monkeypatch, {"capacity": battery_service._HOST_READ_ATTEMPTS - 1})
|
|
svc = _service([])
|
|
asyncio.run(svc._poll())
|
|
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)
|
|
monkeypatch.setattr(battery_service, "_HOST_RETRY_S", 0)
|
|
|
|
|
|
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([])
|
|
asyncio.run(svc._poll())
|
|
assert svc.reading().percent == 94
|
|
|
|
|
|
def test_a_big_step_waits_for_the_next_poll(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 = []
|
|
for _ in range(5):
|
|
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.
|
|
assert shown == [94, 94, 93, 93, 60]
|
|
|
|
|
|
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
|