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>
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>
With the Cap powered, I2C bus 1 doesn't only fail reads: some come
back garbled (a capacity of 56414, clamped to a confident 100), and
the kernel's bq27xxx driver caches whatever it read, failure or
garbage, for five seconds. Retrying 0.1 s apart only re-read the
cache, so every retry missed.
Now an unreadable or out-of-range percent is asked again 5.5 s later,
past the cache; a step of more than 5 points between polls waits for
the next poll to confirm it, which catches garbage that lands inside
0-100; and the sysfs reads leave the event loop, since a failing
transfer on that bus takes its time. Measured on the device with the
Cap on: a steady 96%, where it showed nothing or 100 before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tried on a real Cardputer Zero for the first time. It draws on the
panel, takes the keyboard, and opens from M5's app launcher:
scripts/cardputer-zero/launcher-entry.sh puts a source checkout there
(and `remove` takes it off) until there is a store package.
Three things the hardware showed:
- The splash opened with the highlight on /dev/ttyS0, the Cap's GPS
UART, because serial is listed first as found. Below the companions
we've used, anything that looks like one now comes before a port
nothing vouches for, across transports.
- The title bar's battery is the handheld's own BQ27220, as the
PicoCalc's is. The host pack is now found by what it is (a Battery
supply that isn't a peripheral's), not by the PicoCalc's name.
- While the Cap is powered, I2C bus 1 turns noisy: a quarter to three
quarters of the gauge's reads fail with an I/O error (one in ten of
the clock's), none with the header off. A failed read is asked again,
a status that won't read keeps its last verdict, and a good reading
stands through six failed polls instead of blinking out.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The charger cuts out at full, the pack settles back to 99, the charger
restarts - and the companion reports that flip every poll for as long
as the thing is plugged in. The gauge is pinned to the header's right
edge and the pulse takes whatever is left, so the fourth cell 100%
needed came off the sparkline and redrew the whole activity history at
a new scale, twice a minute, on a device sitting still.
A full pack reads `100` now, with no sign: three cells, exactly as the
`99%` it keeps flipping back to. It is the one reading whose `%` can
be inferred - a bare 100 beside a full block is a percentage without
being told - and the sign goes on doing its work at every charge that
could be mistaken for something else.
Only that pair is answered, deliberately. The other width change in
the range, 9 to 10, is a boundary a discharging pack crosses once and
never comes back over: one repaint, no jitter, nothing to fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.
Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.
What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.
The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.
Add this commit to `.git-blame-ignore-revs`, which follows immediately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`E501` across 69 lines, wrapped rather than exempted.
The prose ones — module docstrings in `menu`, `node_detail_screen`,
`pathline`, `pathgraph`, `walk_screen`, `trace_runner` and `test_about` — are
reflowed as paragraphs, so the block keeps its shape instead of gaining one
short orphan line. Nothing they say changed.
The code ones mostly gained a line break inside a call. Where a break would
have read worse than a name, the sub-expression got one instead: `reach` in
`topology`'s quality clamp, `plain_cells` in `pathline`, `numbering` in the
trace list, `listed` in `flash.py`'s refusal, and a small `shape`/`is_strip`
helper in two tests that were comparing or matching the same shape twice on one
line. Four docstring summaries lost a word they were not using.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One braille cell was four coarse levels lit in one colour. It is now a
filled block whose two channels answer two different questions.
Colour answers "roughly how much" from across the room: a lit
foreground over its own darker ground of the same hue — light green on
green over half, yellow on brown over a quarter, light red on red
below. The braille fill inside the block answers "exactly how much" on
a second look: each dot row lights left before right, so one cell
carries eight rungs, one per 12.5% of charge. The band holds still
while the dots climb through it, which is what keeps the two readable
as two.
The charging sweep keeps its five whole-row frames and does not colour
itself — a frame of an animation is not a reading, so the band goes on
answering for the real charge underneath it, exactly as the number
beside it does.
That leaves the alarm nothing to say in hue, so it says it in ground:
under 6.25% — half the last dot's worth of charge — the cell alternates
black dots on red with the light red on the bare page. Where every
other cell is filled, a beat with no fill at all is the loudest thing
the palette has. It is drawn a frame at a time because a terminal's own
blink only ever toggles the foreground, and the PicoCalc's framebuffer
console ignores SGR 5 outright — device-checked, along with its want of
bright backgrounds, which is why the grounds are all dim-bank slots.
Neither animation sits behind Platform.effects any more. The header is
rebuilt on the idle tick regardless, so both cost a colour swap in a
frame already being painted, and the one platform the alarm is *for*
was the one platform never drawing it. effects keeps the braille
spinner, which is what it was really gating.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep read a frame per idle repaint, so it climbed at half speed on the
handheld — a platform that ticks every 2 s because a frame costs 7-11% of a
core there. Pacing it off the tick was solving the wrong problem: the gauge
should climb at a second a frame everywhere, and the repaints that takes are
worth buying, since a pack only sweeps while it is plugged in.
So the poller now buys them, and only while all three hold: the gauge is
actually sweeping (a pack sitting full on the charger is not — which is where
a forgotten handheld spends the night), the platform idles slower than a
frame, and a surface is listening. Everything else sleeps out the interval as
before. BATTERY_ANIM_S moves to the widget as the one cadence both read, and
Ui.invalidate gives a service its first channel to repaint live chrome.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gauge says charging by sweeping its fill bottom-to-full, holding the
charge colour. That is the state itself, not decoration, so gating it on
Platform.effects left the PicoCalc with no way to say it at all — and,
worse, frozen on the sweep's empty frame. Only the low-battery blink is
decoration over a charge the cell already shows; that alone stays behind
`effects`, and `animate` now says so.
The sweep steps on the platform's own idle repaint interval rather than a
fixed second, so each repaint advances it one clean step instead of
aliasing across skipped frames: 1 s on the desktop, 2 s on the handheld.
A pack at 100% draws as not charging whichever way the flag reads — a full
cell resting full is the honest picture, and a charger left plugged in
shouldn't leave the gauge sweeping forever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PicoCalc reads its charging flag straight off the handheld's own BMS
— the kernel's power_supply status, backed by the keyboard MCU's battery
register — so the state was known all along; the header simply never drew
it. Charging replaces the fill with a sweep frame, and a platform without
effects holds frame 0, so a charging pack drew an *empty* cell: the gauge
under-read the pack outright, exactly while it was filling.
Animation is now the caller's claim rather than something inferred from a
held frame. Where nothing animates the gauge draws its resting state — the
true fill, no blink — and says charging with an arrow beside it instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The companion protocol reports only a battery voltage, so charging is a
voltage-trend inference. Some devices could instead expose the standard
GATT Battery Service (0x180F) whose Battery Level Status characteristic
(0x2BED, Battery Service 1.1) carries a real, firmware-reported charging
flag. Dumping the GATT table shows no current MeshCore build implements
it — the BLE profile is just the Nordic UART pipe plus DFU — but wire it
up now so it lights up automatically the day one does.
get_hw_charging() reads 0x2BED best-effort over BLE (reaching the bleak
client the forced-teardown path already uses) and prefers its flag; any
failure — service absent, read refused, short value, non-BLE transport —
is a quiet None, so the poller falls back to the inference and every
current device behaves exactly as before. Verified against a live device:
the read resolves and returns a clean, steady None. The 0x2BED bitfield
decode is a pure function unit-tested against the spec's Charge State enum
so the parse is correct ahead of any hardware that populates it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The firmware exposes no charging flag, so charging is inferred from the
terminal-voltage trend. The old test compared the first and last samples
in the window against a 15 mV threshold — but a single-cell LiPo sags
tens of millivolts under each LoRa transmission and springs back when the
radio idles, so a lone sagged sample at either endpoint read as a
+tens-of-mV "rise" and the gauge falsely showed charging on a discharging
or resting pack.
Read the trend from the median of the window's older half against the
median of its newer half (discarding the transient sag/recovery spikes)
and require a large, sustained rise, with hysteresis: hard to start
(25 mV), held while still clearly climbing (8 mV), and dropped the moment
the pack goes flat — topped off or unplugged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a one-cell braille battery indicator to the status bar, shown only when
the companion reports a pack. The cell fills bottom-to-top in four tiers —
green healthy, orange at a quarter, red near empty — with the true percent
beside it; it blinks red↔dim when critically low and sweeps a filling
animation while charging. A session-long BatteryService polls the pack,
maps millivolts to a state-of-charge estimate off a single-cell LiPo curve,
and infers charging from a rising terminal voltage (firmware exposes no
charging flag). The header pins the gauge flush right and the activity
pulse cedes exactly the cells it needs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>