diff --git a/meshterm/services/battery_service.py b/meshterm/services/battery_service.py index 9a9fa17..e8dff70 100644 --- a/meshterm/services/battery_service.py +++ b/meshterm/services/battery_service.py @@ -32,9 +32,12 @@ import asyncio import time from collections import deque from dataclasses import dataclass +from pathlib import Path from statistics import median from typing import TYPE_CHECKING, Optional +from ..platforms import get_platform + if TYPE_CHECKING: from ..context import AppContext @@ -68,6 +71,10 @@ _CHARGING_RISE_HOLD_MV = 8 #: How many samples to retain — the trend window plus a little slack at the poll cadence. _TREND_SAMPLES = int(_TREND_WINDOW_S / POLL_S) + 2 +#: The PicoCalc's own battery gauge, a standard kernel ``power_supply`` driver +#: (confirmed present in P0: ``capacity``, ``status``, Li-ion, uevent format). +_HOST_SUPPLY = Path("/sys/class/power_supply/picocalc") + #: Single-cell LiPo terminal-voltage → state-of-charge lookup ``(millivolts, percent)``, #: high to low. The discharge curve is far from a straight line — most of the usable charge #: sits in a narrow band around 3.7–3.9 V — so a piecewise table read with linear @@ -185,7 +192,16 @@ class BatteryService: await asyncio.sleep(POLL_S) async def _poll(self) -> None: - """One pass: read the pack, estimate charge, and re-derive the charging trend.""" + """One pass: read the pack, estimate charge, and re-derive the charging trend. + + Which pack depends on the platform (``Platform.battery``): the PicoCalc's gauge + reports the *handheld's* cells through the kernel's standard ``power_supply`` + driver — a percent and a real charging flag, no estimation needed — while the + regular platform polls the connected companion radio over the mesh link. + """ + if get_platform().battery == "host": + self._poll_host() + return ctx = self._ctx if not ctx.is_connected: return @@ -211,6 +227,31 @@ class BatteryService: charging=charging, ) + def _poll_host(self) -> None: + """Read the host's own pack from sysfs (the PicoCalc's ``picocalc`` supply). + + The driver is a standard ``power_supply``: ``capacity`` is a true percent and + ``status`` a real charging flag (verified in P0), so none of the companion + path's voltage-curve or trend estimation applies. An unreadable supply (driver + missing, permissions) reports *absent* and the header simply draws no gauge. + """ + supply = _HOST_SUPPLY + try: + percent = int((supply / "capacity").read_text().strip()) + status = (supply / "status").read_text().strip() + except (OSError, ValueError): + self._reading = None + return + try: # informational only; the header draws percent + charging + mv = int((supply / "voltage_now").read_text().strip()) // 1000 + except (OSError, ValueError): + mv = 0 + self._reading = BatteryReading( + millivolts=mv, + percent=max(0, min(100, percent)), + charging=status == "Charging", + ) + def _charging(self, now: float) -> bool: """Infer whether the pack is charging from its recent terminal-voltage trend. diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index d1c9e12..d48ecf4 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -669,8 +669,13 @@ _fold_impl: Callable[[str], str] = _no_fold @on_platform def _bind(platform: Platform) -> None: """Bind the theme's platform-dependent choices (runs now and on every switch).""" - global _ACTIVE_THEME, _name_impl, _glyph_impl, _fold_impl + global _ACTIVE_THEME, _name_impl, _glyph_impl, _fold_impl, _FOLD_TABLE _ACTIVE_THEME = MESH_THEME if platform.truecolor else MESH_THEME_16 _name_impl = _name_style_by_key if platform.name_colour == "key" else _name_style_by_type _glyph_impl = _glyph_identity if platform.emoji else _glyph_compact _fold_impl = _fold_to_font if platform.ascii_fold else _no_fold + # The fold table's emoji pads are computed with cell_len at build time. Cell widths + # can be re-measured/patched (the emoji-width calibration on the regular platform), + # so a platform switch drops the table and cache rather than trusting stale pads. + _FOLD_TABLE = None + _fold_to_font.cache_clear() diff --git a/scripts/calculinux-console-font.sh b/scripts/calculinux-console-font.sh index c6affda..f4efae7 100644 --- a/scripts/calculinux-console-font.sh +++ b/scripts/calculinux-console-font.sh @@ -1,44 +1,47 @@ #!/bin/sh -# calculinux-console-font.sh -- build, install, and persist the "meshterm" console font. +# calculinux-console-font.sh -- build, install, and persist the "meshterm" console fonts +# and the 16-slot palette. # # MeshTerm runs on a Luckfox Lyra inside a ClockworkPi PicoCalc under Calculinux, drawing # to the bare ILI9488 framebuffer console (fbcon). That console loads a single PSF font, # and every font Calculinux ships is a classic 256-glyph VGA font -- so out of the box the -# panel cannot draw the four things MeshTerm's UI leans on hardest: +# panel cannot draw what MeshTerm's UI leans on hardest: braille charts (U+2800-U+28FF), +# the node/status marks, the P3 compact-icon marks, rounded panel corners, or the list +# cursor. This script synthesizes TWO 512-glyph PSF2 fonts: # -# * braille charts U+2800-U+28FF (256 codepoints -- a whole block on their own) -# * node / status marks * filled circle, fisheye, star, em-dash, check, cross, play -# * rounded panel corners Rich's ROUNDED box emits U+256D/E/F U+2570, absent from CP437 -# * the list cursor U+276F "heavy angle" + U+25B8 small triangle (reorder state) +# * meshterm.psf.gz 6x12 (Terminus base) -> 53x26 -- the installed default +# * meshterm8.psf.gz 6x8 (kernel font_6x8) -> 53x40 -- the A/B candidate # -# Missing glyphs render blank, so charts vanish, node rows lose their icons, every window -# frame loses its corners, and the highlighted menu row shows a two-space gap where its -# pointer should be. This script synthesizes all of them and installs one 512-glyph PSF2. +# Flip live with `setfont /usr/share/consolefonts/meshterm8.psf.gz` (and back with +# meshterm.psf.gz); persist the winner via FONT= in /etc/vconsole.conf. # -# Why 512 and not more: fbcon caps a font at 512 glyphs. Braille alone is 256, and the base -# Terminus set is another 256 -- that already fills the budget exactly. So the marks and -# cursors do NOT grow the table: each is drawn into a DONOR slot (a useless CP437 pictograph -# -- smiley, card suit, music note -- that MeshTerm never emits), whose old codepoint is -# dropped from the Unicode map and repointed at the new glyph. The rounded corners cost no -# glyph at all: a 1-2px curve is meaningless at 6x12, so each rounded corner is aliased onto -# the existing square-corner glyph (an extra codepoint on one entry -- the frame just closes). +# Why 512 and not more: fbcon caps a font at 512 glyphs. Braille alone is 256 and the base +# set is another 256 -- the budget is already full, so every extra mark is drawn into a +# DONOR slot (a pictograph MeshTerm never emits) whose codepoint is repointed. Choosing a +# donor means knowing its slot's FULL codepoint list: bases alias lookalikes onto one glyph +# (donating pi once erased Cyrillic pe), so a donor miss aborts the build and a keeper list +# is verified after it. The rounded corners and the midline ellipsis cost no glyph: they +# are aliased onto existing bitmaps. # -# Everything is pure geometry, generated on-device, so nothing but the stock Terminus font -# and python3 is required. Run it as root on the Lyra (over the serial console is fine): +# The 6x8 base is the Linux kernel's own font_6x8 (lib/fonts/font_6x8.c, GPL-2.0), +# embedded below -- CP437 coverage, so unlike the Terminus base it has NO Cyrillic; +# its keeper list drops the Cyrillic canary accordingly. +# +# Everything else is pure geometry, generated on-device; only the stock Terminus font and +# python3 are required. Run as root on the Lyra (serial console is fine): # # sh calculinux-console-font.sh # -# It is idempotent -- safe to re-run after a MeshTerm update or a font tweak. -# -# Scope: this covers ONLY the console-font configuration. The rest of the Calculinux bring-up -# (opkg python3-modules/pip, the venv + `pip install -e .`, the Wi-Fi boot-scan kick) is a -# one-time deploy done separately and is not repeated here. +# Idempotent -- safe to re-run after a MeshTerm update or a font tweak. Also installs +# /etc/vtrgb + the meshterm-vtrgb boot oneshot (the 16-slot palette; see +# meshterm/ui/theme._VT_SLOTS -- a repo test pins the two to each other). set -eu FONT_NAME=meshterm CONSOLEFONTS=/usr/share/consolefonts BASE="$CONSOLEFONTS/ter-u12n.psf.gz" # stock Terminus 6x12, 256 glyphs -OUT="$CONSOLEFONTS/$FONT_NAME.psf.gz" # what we build +OUT="$CONSOLEFONTS/$FONT_NAME.psf.gz" # the 6x12 default +OUT8="$CONSOLEFONTS/${FONT_NAME}8.psf.gz" # the 6x8 A/B candidate VCONSOLE=/etc/vconsole.conf # --- preflight: fail early with a plain reason, never half-apply ----------------------- @@ -47,36 +50,40 @@ command -v python3 >/dev/null 2>&1 || { echo "error: python3 not found" >&2; exi command -v setfont >/dev/null 2>&1 || { echo "error: setfont not found (install kbd tools)" >&2; exit 1; } [ -f "$BASE" ] || { echo "error: base font not found: $BASE" >&2; exit 1; } -# --- build meshterm.psf.gz from the stock Terminus font -------------------------------- +# --- build both fonts ------------------------------------------------------------------ # The generator is inlined (quoted heredoc, so the shell expands nothing) and reads its # paths from the environment. It is the single source of truth for the synthesized glyphs. -echo "building $OUT from $(basename "$BASE") ..." -BASE="$BASE" OUT="$OUT" python3 - <<'PYEOF' -import os, struct, gzip +echo "building $OUT (6x12) and $OUT8 (6x8) ..." +BASE="$BASE" OUT="$OUT" OUT8="$OUT8" python3 - <<'PYEOF' +import base64, os, struct, gzip BASE = os.environ["BASE"] OUT = os.environ["OUT"] +OUT8 = os.environ["OUT8"] PSF2_MAGIC = 0x864AB572 -# --- braille: 2-wide x 4-tall dot grid; the codepoint's low byte says which dots lit --- +# --- braille: 2-wide dot grid; the codepoint's low byte says which dots lit ------------ COLS = [[1, 2], [4, 5]] -ROWS = [[0, 1], [3, 4], [6, 7], [9, 10]] BIT = {0: (0, 0), 1: (0, 1), 2: (0, 2), 6: (0, 3), 3: (1, 0), 4: (1, 1), 5: (1, 2), 7: (1, 3)} +#: Dot-row bands per cell height: 3px pitch at 6x12, 2px at 6x8. +BANDS12 = [[0, 1], [3, 4], [6, 7], [9, 10]] +BANDS8 = [[0, 1], [2, 3], [4, 5], [6, 7]] -def braille_glyph(value): - rows = [0] * 12 + +def braille_glyph(value, bands, height): + rows = [0] * height for bit in range(8): if value >> bit & 1: col, row = BIT[bit] for x in COLS[col]: - for y in ROWS[row]: + for y in bands[row]: rows[y] |= 0x80 >> x return bytes(rows) -# --- MeshTerm marks + cursors, drawn as 6x12 pixel art ('#' lit) ----------------------- +# --- marks + cursors, drawn as pixel art ('#' lit) ------------------------------------- def art(rows): return bytes(sum(0x80 >> x for x, ch in enumerate(r) if ch == "#") for r in rows) @@ -140,24 +147,60 @@ MARKS = { "##..##", "##..##", "######", "######", "......", "......"]), } +# The same 18 marks redrawn for the 6x8 cell (first-draft art; the P5 tweak round and +# JP's eyeball pass refine whichever font wins the A/B). +MARKS8 = { + 0x25CF: art([ # BLACK CIRCLE + "......", "..##..", ".####.", "######", "######", ".####.", "..##..", "......"]), + 0x25C9: art([ # FISHEYE + "......", "..##..", ".#..#.", "#.##.#", "#.##.#", ".#..#.", "..##..", "......"]), + 0x2605: art([ # BLACK STAR + "......", "..#...", "..#...", "######", ".####.", "..##..", ".#..#.", "......"]), + 0x2014: art([ # EM DASH + "......", "......", "......", "######", "######", "......", "......", "......"]), + 0x2713: art([ # CHECK MARK + "......", "......", ".....#", "....#.", "#..#..", ".##...", ".#....", "......"]), + 0x2717: art([ # BALLOT X + "......", "#...#.", ".#.#..", "..#...", ".#.#..", "#...#.", "......", "......"]), + 0x25B6: art([ # RIGHT-POINTING TRIANGLE + "#.....", "##....", "###...", "####..", "####..", "###...", "##....", "#....."]), + 0x276F: art([ # HEAVY RIGHT ANGLE QUOTE -- the list cursor + "##....", ".##...", "..##..", "...##.", "..##..", ".##...", "##....", "......"]), + 0x25B8: art([ # SMALL RIGHT-POINTING TRIANGLE -- reorder cursor + "......", "......", ".#....", ".##...", ".###..", ".##...", ".#....", "......"]), + 0x2026: art([ # HORIZONTAL ELLIPSIS + "......", "......", "......", "......", "......", "#.#.#.", "#.#.#.", "......"]), + 0x26A0: art([ # WARNING SIGN + "..##..", ".#..#.", "#.##.#", "#.##.#", "#....#", "#.##.#", "######", "......"]), + 0x232B: art([ # ERASE TO THE LEFT + "......", "..####", ".##.##", "#..#.#", ".##.##", "..####", "......", "......"]), + 0x21E7: art([ # UPWARDS WHITE ARROW -- shift + "..##..", ".#..#.", "#....#", "##..##", ".#..#.", ".#..#.", ".####.", "......"]), + 0x2699: art([ # GEAR + "......", "..##..", "######", "##..##", "##..##", "######", "..##..", "......"]), + 0x21BB: art([ # CLOCKWISE OPEN CIRCLE ARROW -- refresh + "....#.", ".#####", "#...#.", "#.....", "#.....", "#....#", ".####.", "......"]), + 0x25F7: art([ # CLOCK FACE + "......", ".####.", "#..#.#", "#..###", "#....#", "#....#", ".####.", "......"]), + 0x2316: art([ # POSITION INDICATOR -- crosshair + "..##..", "......", "#.##.#", "#.##.#", "......", "..##..", "......", "......"]), + 0x26BF: art([ # SQUARED KEY -- padlock + ".####.", ".#..#.", "######", "##..##", "##..##", "######", "######", "......"]), +} + # Donor codepoints whose glyph slots we may repurpose (glyphs MeshTerm never draws). -# Order matters: MARKS consume donors front to back, one each, so the original nine marks -# keep their original slots and the P3 nine take the next batch. Choosing a donor means -# knowing its slot's FULL codepoint list: ter-u12n aliases lookalikes onto one glyph, and -# repointing the slot erases every codepoint it carried -- donating pi (03C0) was tried -# and took Cyrillic pe (043F) with it, and the beamed notes 266B/266C share one slot (so -# 266B's donation already erases both; never list 266C as a donor of its own -- it would -# come up empty and abort the build). After the consumed batch: preferred future donors -# (the mixed double/single box set nothing draws), then the last-resort tail -- those are -# MAP TARGETS in meshterm/ui/theme (advert ☼, message ¶, data §, packet ▬, control ↨); -# consuming one breaks the compact icon language. +# Order matters: MARKS consume donors front to back, one each. See the header comment +# for the shared-slot trap; the tail donors double as MAP TARGETS in meshterm/ui/theme +# (advert / message / data / packet / control marks) and are last-resort spares only. +# NOTE 266C: in the Terminus base it shares 266B's slot (a freebie, never a donor of its +# own -- listing it would abort); it is NOT in this list for that reason. DONORS = [0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x25D8, 0x25D9, 0x266A, 0x266B, 0x203C, 0x2640, 0x2642, 0x2320, 0x2321, 0x00F7, 0x2552, 0x2558, 0x2559, 0x255B, 0x255E, 0x255F, 0x2561, 0x2567, 0x2568, 0x256A, 0x263C, 0x00B6, 0x00A7, 0x25AC, 0x21A8] # Rounded panel corners aliased onto the existing square corners (no new bitmap), and the -# midline ellipsis onto the P3 baseline one (pathline's gap marker draws as the same dots). +# midline ellipsis onto the baseline mark. ALIASES = { 0x256D: 0x250C, # rounded top-left -> square top-left 0x256E: 0x2510, # rounded top-right -> square top-right @@ -166,75 +209,131 @@ ALIASES = { 0x22EF: 0x2026, # midline ellipsis -> ellipsis mark } +# Codepoints that must survive both builds: the theme's base-font map targets. +KEEP_COMMON = [0x263C, 0x00B6, 0x00A7, 0x25AC, 0x21A8] + + +def build(glyphs, entries, charsize, height, marks, bands, keep_extra, out_path): + """Append braille, draw marks into donors, alias, verify, and write one PSF2.""" + glyphs = [bytearray(g) for g in glyphs] + entries = list(entries) + glyphs += [bytearray(braille_glyph(v, bands, height)) for v in range(256)] + entries += [chr(0x2800 + i).encode("utf-8") for i in range(256)] + + def slot_of(cp): + needle = chr(cp).encode("utf-8") + for i, e in enumerate(entries): + if needle in e: + return i + return None + + donors = list(DONORS) + for cp, bitmap in marks.items(): + if not donors: + raise SystemExit("out of donor slots for U+%04X" % cp) + donor = donors.pop(0) + slot = slot_of(donor) + if slot is None: + raise SystemExit( + "donor U+%04X (for mark U+%04X) not found in the base font -- " + "fix DONORS instead of skipping" % (donor, cp)) + padded = bytearray(bitmap) + padded += bytes(charsize - len(padded)) + glyphs[slot] = padded + entries[slot] = chr(cp).encode("utf-8") + + for new_cp, existing_cp in ALIASES.items(): + slot = slot_of(existing_cp) + if slot is None: + raise SystemExit("no glyph for U+%04X to alias U+%04X onto" % (existing_cp, new_cp)) + entries[slot] += chr(new_cp).encode("utf-8") + + for cp in list(marks) + list(ALIASES) + KEEP_COMMON + keep_extra: + if slot_of(cp) is None: + raise SystemExit("build ate U+%04X -- a donor slot carried it; fix DONORS" % cp) + + header = struct.pack("> "$VCONSOLE" fi echo "persisted FONT=$FONT_NAME in $VCONSOLE (loads on every boot)" +echo "A/B: 'setfont $OUT8' for 53x40, 'setfont $OUT' for 53x26; persist the winner in $VCONSOLE" # --- palette: program the 16 console slots to MeshTerm's colours ------------------------ # The panel's VT layer is 16 fg / 8 bg palette slots -- no per-cell RGB -- so MeshTerm's diff --git a/scripts/calculinux-setup.sh b/scripts/calculinux-setup.sh index 952b591..1abe45c 100644 --- a/scripts/calculinux-setup.sh +++ b/scripts/calculinux-setup.sh @@ -163,6 +163,11 @@ else (usermod -aG wheel "$DEPLOY_USER" 2>/dev/null || adduser "$DEPLOY_USER" wheel 2>/dev/null) || true info "no password set -- run: passwd $DEPLOY_USER" fi +# input -> the F-key lane's Shift watcher reads /dev/input (services/modifier_watch); +# dialout/video match the working device (serial radios, framebuffer). All idempotent. +for grp in input dialout video; do + (usermod -aG "$grp" "$DEPLOY_USER" 2>/dev/null || adduser "$DEPLOY_USER" "$grp" 2>/dev/null) || true +done # --- 4. clone MeshTerm (read-only deploy key over SSH) --------------------------------- log "4/7 clone MeshTerm" diff --git a/tests/test_picocalc_chrome.py b/tests/test_picocalc_chrome.py index 78dc0cb..b0ae788 100644 --- a/tests/test_picocalc_chrome.py +++ b/tests/test_picocalc_chrome.py @@ -86,6 +86,29 @@ def test_fkey_lane_text_fits_and_flips() -> None: assert cell_len(lane_text(wide, shifted=True).plain) <= 53 +def test_host_battery_reads_the_sysfs_supply(tmp_path, monkeypatch) -> None: + """The PicoCalc battery path: a true percent and charging flag straight from sysfs.""" + from meshterm.services import battery_service + + (tmp_path / "capacity").write_text("53\n") + (tmp_path / "status").write_text("Discharging\n") + (tmp_path / "voltage_now").write_text("4012000\n") + monkeypatch.setattr(battery_service, "_HOST_SUPPLY", tmp_path) + service = battery_service.BatteryService(ctx=None) + service._poll_host() + reading = service.reading() + assert reading is not None + assert (reading.percent, reading.charging, reading.millivolts) == (53, False, 4012) + + (tmp_path / "status").write_text("Charging\n") + service._poll_host() + assert service.reading().charging is True + + monkeypatch.setattr(battery_service, "_HOST_SUPPLY", tmp_path / "gone") + service._poll_host() + assert service.reading() is None # unreadable supply = absent, header draws nothing + + def test_dialog_gate_shrinks_on_picocalc() -> None: from meshterm.ui.surface import _dialog_max_cells