P5: device integration - host battery, the 6x8 A/B font, deploy updates

- BatteryService grows the host provider (Platform.battery="host"): the
  PicoCalc's own pack through the kernel power_supply driver - a true
  percent and charging flag from sysfs, no voltage-curve estimation, no
  radio connection needed. Unreadable supply = absent, gauge hidden.
- The console-font script now builds TWO 512-glyph PSF2 fonts: the 6x12
  Terminus-based default (53x26) and a 6x8 A/B candidate (53x40) based
  on the kernel's own font_6x8 (CP437 coverage - documented as having
  no Cyrillic), with the braille block at 2px dot pitch and all 18
  marks redrawn for the shorter cell. Built and verified live on the
  Lyra: stty reports 53x40 under meshterm8, specimen legible
  (.claude/plans/p5-specimen-6x8.png); flip with setfont, persist the
  winner in vconsole.conf. JP judges the A/B by eye.
- calculinux-setup.sh puts the deploy user in input/dialout/video
  (input is the modifier watch's /dev/input access).
- theme's platform binding now drops the fold table and cache on every
  switch: its emoji pads bake in cell_len at build time, which the
  regular platform's emoji-width calibration can re-measure - a stale
  table survived platform swaps in randomized test runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-08-01 13:08:17 -04:00
1 parent 2b658f47a9
commit 89eef7f4d2
5 files changed
+279 -105

No files matched your search

+42 -1
View File
@@ -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.
+6 -1
View File
@@ -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()
+203 -103
View File
@@ -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("<IIIIIIII", PSF2_MAGIC, 0, 32, 1, 512, charsize, height, 6)
out = header + b"".join(bytes(g) for g in glyphs) + b"".join(e + b"\xff" for e in entries)
gzip.open(out_path, "wb").write(out)
print(" wrote %s: 512 glyphs (6x%d), +%d marks, +%d aliases"
% (out_path, height, len(marks), len(ALIASES)))
# --- 6x12: the Terminus base -----------------------------------------------------------
base = gzip.open(BASE, "rb").read()
magic, ver, hsize, flags, length, charsize, h, w = struct.unpack("<IIIIIIII", base[:32])
assert magic == PSF2_MAGIC and (length, charsize, h, w) == (256, 12, 12, 6), \
"base is not the expected Terminus 6x12 PSF2 (%r)" % ((length, charsize, h, w),)
glyphs12 = [base[32 + i * charsize:32 + (i + 1) * charsize] for i in range(256)]
entries12 = base[32 + 256 * charsize:].split(b"\xff")[:256]
# Cyrillic pe + pi: the shared-slot canaries (donating pi once erased pe).
build(glyphs12, entries12, 12, 12, MARKS, BANDS12, [0x043F, 0x03C0], OUT)
# Keep all 256 Terminus glyphs, append 256 procedurally-drawn braille cells.
glyphs = [bytearray(base[32 + i * charsize:32 + (i + 1) * charsize]) for i in range(256)]
glyphs += [bytearray(braille_glyph(v)) for v in range(256)]
# Unicode table: one raw entry per glyph (0xFF never appears inside UTF-8, so it splits clean).
entries = base[32 + 256 * charsize:].split(b"\xff")[: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
# Draw each mark/cursor into a donor slot: overwrite the bitmap, repoint the codepoint.
# One donor per mark, and a donor that fails to resolve aborts the build: a miss means
# the DONORS list's model of the base font is wrong, and silently consuming the *next*
# donor is how a keeper glyph gets eaten (it cost us ☼ once).
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))
glyphs[slot] = bytearray(bitmap)
entries[slot] = chr(cp).encode("utf-8")
# Alias rounded corners onto the square-corner glyphs (extra codepoint, same bitmap).
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")
# Verify nothing the app depends on was eaten as donation collateral (a donor slot can
# carry codepoints beyond the one that earned it a place on the list). The keepers: every
# mark and alias just placed, the compact icon language's base-font targets, and Cyrillic
# pe (043F) as the canary for the pi/pe shared slot that bit once.
KEEP = list(MARKS) + list(ALIASES) + [
0x263C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, # ☼ ¶ § ▬ ↨ -- theme map targets
0x043F, 0x03C0, # п and π -- shared-slot canaries
# --- 6x8: the kernel's font_6x8 (CP437 coverage -- no Cyrillic) ------------------------
FONT8 = base64.b64decode("""
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
""")
assert len(FONT8) == 2048
glyphs8 = [FONT8[i * 8:(i + 1) * 8] for i in range(256)]
# CP437's graphics mapping: the control range 0x00-0x1F holds pictographs, 0x7F a house;
# the rest decodes through Python's cp437 codec.
CP437_LOW = [
0x0000, 0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022,
0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C,
0x25BA, 0x25C4, 0x2195, 0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8,
0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
]
for cp in KEEP:
if slot_of(cp) is None:
raise SystemExit("build ate U+%04X -- a donor slot carried it; fix DONORS" % cp)
header = struct.pack("<IIIIIIII", PSF2_MAGIC, 0, 32, 1, 512, charsize, h, w)
out = header + b"".join(bytes(g) for g in glyphs) + b"".join(e + b"\xff" for e in entries)
gzip.open(OUT, "wb").write(out)
print(" wrote 512 glyphs: 256 base + 256 braille, +%d marks/cursors, +%d corner aliases"
% (len(MARKS), len(ALIASES)))
entries8 = []
for i in range(256):
if i < 0x20:
cp = CP437_LOW[i]
elif i == 0x7F:
cp = 0x2302
else:
cp = ord(bytes([i]).decode("cp437"))
entries8.append(chr(cp).encode("utf-8") if cp else b"")
build(glyphs8, entries8, 8, 8, MARKS8, BANDS8, [0x03C0], OUT8)
PYEOF
# --- apply live: setfont re-renders the whole console immediately -----------------------
TTY=/dev/tty1
[ -c "$TTY" ] || TTY=/dev/tty0
echo "applying to $TTY ..."
echo "applying $OUT (6x12, the default) to $TTY ..."
setfont -C "$TTY" "$OUT"
# --- persist: systemd-vconsole-setup reads FONT= from vconsole.conf at every boot -------
@@ -244,6 +343,7 @@ else
echo "FONT=$FONT_NAME" >> "$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
+5
View File
@@ -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"
+23
View File
@@ -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