Files
firmware/qa/replica/test_boot_screen.py
T
Oleksandr Shyshatskyi 80a4d64ffd Prepare next firmware release
Add Fluidd and custom boot scripts, migrate DC24V control, update the web UIs and filament macros, improve startup status reporting, and expand upgrade and replica coverage.
2026-09-24 11:37:28 +03:00

407 lines
19 KiB
Python

"""The boot screen, drawn by the printer's own interpreter.
A port of the retired test/integration/printer/case-boot-screen.sh.
WHAT THIS ADDS OVER test/integration/test_ffscreen.py. That file is the
host-side unit coverage -- pixel formats, buffer bounds, rotation arithmetic,
degradation to "no screen" -- and it runs ffscreen.py on the DEVELOPER's
CPython. None of it can say whether the file executes on the machine it ships
to. What only a replica can answer:
* whether $MODDIR/bin/python3.13 -- our own cross-built CPython 3.13,
running here under qemu-mipsel -- executes this code at all. ffscreen.py
is stdlib-only and that has been reasoned about; reasoning has not been
enough before, so the interpreter runs it.
* whether anvil-env.sh, the file every real caller sources, actually
resolves FF_PYTHON to that interpreter.
* whether ff-startup.py finds its two siblings when run BY PATH, which is
the only way its plain-name imports resolve. A silently missing
ff_mcu_bringup means the boards are never handed over and klippy opens
ports at bootloaders; a silently missing ffscreen means a black panel
forever. Both imports are optional by design, so neither failure is loud.
* whether the bytes land where the arithmetic says, on this kernel and this
interpreter. A stride or bpp error produces a sheared panel, not an
exception, so the check has to be on the buffer itself.
WHAT IS REAL HERE AND WHAT IS NOT
NO PANEL IS INVOLVED. There is no framebuffer driver, no display and no
kernel fb device in the replica: assemble.sh:103 creates /dev/fb0 as an empty
REGULAR FILE (`: > $R/dev/fb0`, so that the stock installer's
`cat start.img > /dev/fb0` works). That is exactly what a framebuffer is from
a writer's point of view -- open, seekless write of one screen's worth of
bytes -- so the code path under test is the real one, but "it drew" here
means "it wrote the right bytes to a file", never "a picture appeared".
test_the_replica_has_only_a_stand_in_framebuffer asserts that stand-in rather
than leaving it as an assumption, because a test that passes when nothing was
drawn is worse than no test.
GEOMETRY IS PASSED EXPLICITLY, for the same reason the case script passed it:
assemble.sh mounts the host's /sys read-only, so a sysfs probe in here would
describe the developer's monitor. The numbers are the machine's real ones --
a PORTRAIT 480x800 framebuffer at 32bpp shown on a landscape 800x480 panel
turned 90 degrees clockwise -- established from FlashForge's own
/usr/prog/start.img, which is 1536000 bytes and only decodes into a picture
when read that way.
WHAT WAS DROPPED FROM THE CASE SCRIPT
* its whole staging preamble -- unpacking /mnt/py.tgz into $MODDIR and
`cp /tmp/payload/bin/*.py` -- and with it gates.py's boot_screen() Skip
when nothing had built an interpreter. The `printer` fixture installs the
real package through the printer's own app_startup.sh, so the
interpreter and the three scripts are there because the INSTALLER put
them there. test_the_installer_placed_the_boot_screen_scripts asserts
that, which is strictly more than the case script could ask: it hand-fed
itself the files it then checked for.
* `ok`/`bad` and the FAIL counter. Each of them is a test below.
Not in scope: tools/replica/printer/case-boot-screen-dump.sh, which renders
frames as base64 PNGs for a human to look at under `make boot-screen-sim`. It
is a viewer, not a test.
"""
import pytest
pytestmark = pytest.mark.replica
MODDIR = "/usr/data/anvil"
PY = MODDIR + "/bin/python3.13"
FB = "/dev/fb0"
# The panel, as the machine really has it: see the module docstring.
W, H, BPP = 480, 800, 32
ONE_SCREEN = W * H * BPP // 8 # 1536000, and start.img's own size
# What the eye sees, once the 90-degree turn is applied.
LANDSCAPE = (800, 480)
SCRIPTS = ("ffscreen.py", "ff-startup.py", "ff_mcu_bringup.py")
# Every action below runs through the file the boot runs through. MODDIR must
# be set BEFORE sourcing: anvil-env.sh's own FF_PYTHON line reads $MODDIR, so
# without it FF_PYTHON resolves to the empty-prefix "/bin/python3.13" -- the
# trap the case script carried an alias for.
ENV = 'MODDIR=%s; . %s/anvil-env.sh; ' % (MODDIR, MODDIR)
# ffscreen.py lives beside the scripts that import it, not on a path any
# interpreter knows about; ff-startup.py gets this for free by being run by
# path, and `python -c` does not.
IMPORT = "import sys; sys.path.insert(0, %r); import ffscreen" % (MODDIR + "/bin")
def _pack32(rgb):
"""One 32bpp pixel, as the usual Linux packing has it: B, G, R, ignored.
Computed HERE rather than by calling ffscreen._pixel inside the replica,
which is what the case script did. That made the buffer check ask the
packing arithmetic to confirm its own output -- a swapped R and B would
have matched itself perfectly. The palette still comes from the shipped
file (see the `palette` fixture); only the packing is independent.
"""
r, g, b = rgb
return bytes((b, g, r, 0xFF))
@pytest.fixture(scope="module")
def box(printer):
"""The installed machine, checked once for the things every test needs.
Once, here, because a package built before the CPython switch fails every
test below for the same single reason, and twelve identical failures hide
which one thing is wrong.
"""
if not printer.file(PY).executable:
pytest.fail(
"this package ships no interpreter for the boot screen: %s is "
"missing, so nothing here can run. FF_PYTHON has pointed at our "
"own cross-built CPython 3.13 since the switch -- build a package "
"from this tree (`make build`) and the replica lane picks up the "
"newest work/out/*.tgz." % PY)
return printer
@pytest.fixture(scope="module")
def palette(box):
"""BACKGROUND, TITLE and BAR_FG as the SHIPPED ffscreen.py defines them.
Read out of the replica rather than repeated here, so that restyling the
screen does not fail this module for a colour change. What is asserted is
that those three colours reach the buffer, not what they are.
"""
got = box.sh(ENV + '"$FF_PYTHON" -c "%s\n'
'print(ffscreen.BACKGROUND, ffscreen.TITLE, ffscreen.BAR_FG)" 2>&1'
% IMPORT)
assert got.ok, "could not read ffscreen's palette: %s" % got.text
numbers = [int(n) for n in got.text.replace("(", " ").replace(")", " ")
.replace(",", " ").split()]
assert len(numbers) == 9, "unexpected palette output: %s" % got.text
return {"background": _pack32(numbers[0:3]),
"title": _pack32(numbers[3:6]),
"bar": _pack32(numbers[6:9])}
@pytest.fixture(scope="module")
def frame(box, palette):
"""Draw one real frame onto the replica's /dev/fb0 and read it back.
ACTION HERE, ASSERTIONS BELOW. The drawing is one shot -- truncate the
device, paint, then reopen it and report facts -- because each `docker
exec` starts a fresh qemu-mipsel interpreter and a test per fact would
pay for twelve of them. Every number this returns is measured inside the
machine; nothing in this fixture decides pass or fail.
The four-byte patterns are counted with `bytes.count`, which is a C-level
scan of 1.5MB rather than a python loop over 384000 pixels on a 1GHz MIPS
core under emulation.
The device is truncated first so that "the background was painted" means
something: the file starts at zero bytes.
"""
box.sh(": > %s" % FB)
drawn = box.sh(ENV + '"$FF_PYTHON" -c "\n'
'%s\n'
"s = ffscreen.Screen(%r, geometry=(%d, %d, %d))\n"
"if not s.ok:\n"
" print('refused: screen is not ok'); raise SystemExit(1)\n"
# The first-boot frame itself: title, status, note,
# progress -- the same call ff-startup.py makes while
# klipper is finding its MCUs.
"s.show('REFORGE IS STARTING', 'READING FACTORY CALIBRATION',\n"
" 'DO NOT TURN THE PRINTER OFF', 0.5)\n"
"buf = open(%r, 'rb').read()\n"
"print('stride', s.stride)\n"
"print('rotate', s.rotate)\n"
"print('surface', s.width, s.height)\n"
"print('size', len(buf))\n"
"print('corner', buf[:4].hex())\n"
# Hex rather than a bytes repr: a repr goes through this
# command's own double quotes, and a palette value that
# happened to be 0x27 would put an apostrophe in there and
# end the shell string early.
"print('background', buf.count(bytes.fromhex('%s')))\n"
"print('title', buf.count(bytes.fromhex('%s')))\n"
"print('bar', buf.count(bytes.fromhex('%s')))\n"
'" 2>&1'
% (IMPORT, FB, W, H, BPP, FB,
palette["background"].hex(), palette["title"].hex(),
palette["bar"].hex()))
facts = {}
for line in drawn.text.splitlines():
parts = line.split()
if len(parts) >= 2:
facts[parts[0]] = parts[1:]
facts["_text"] = drawn.text
facts["_ok"] = drawn.ok
return facts
# ------------------------------------------------- what the machine supplies
def test_the_replica_has_only_a_stand_in_framebuffer(box):
"""Stated as a test so nothing below can be read as more than it is.
assemble.sh makes /dev/fb0 a plain file. If a real character device ever
appeared here, every "it drew" assertion in this module would be asking a
different and much better question -- and this failing is how anyone would
find that out, rather than by trusting the docstring.
"""
assert box.file(FB).exists, (
"%s is absent, so this module tests nothing. assemble.sh creates it "
"as an empty regular file -- see assemble.sh:103." % FB)
kind = box.sh("[ -f %s ] && echo regular || echo other" % FB).first_line
assert kind == "regular", (
"%s is %r, not the regular file assemble.sh creates. If the replica "
"has grown a real framebuffer, this module's claim that no panel is "
"involved is out of date." % (FB, kind))
def test_the_installer_placed_the_boot_screen_scripts(box):
"""The case script COPIED these out of /tmp/payload and then checked they
had arrived, which only ever tested its own `cp`. Here the package was
installed by the printer's own app_startup.sh, so this asks whether the
installer shipped them."""
missing = [name for name in SCRIPTS
if not box.file(MODDIR + "/bin/" + name).exists]
assert not missing, (
"the installed package has no %s in %s/bin -- anvil-core stages "
"pkgs/anvil-core/payload/bin wholesale, so a name missing here means "
"the recipe or the install dropped it"
% (", ".join(missing), MODDIR))
def test_ff_startup_is_executable(box):
"""It is run by path by the s6 service, not by `python ff-startup.py`."""
assert box.file(MODDIR + "/bin/ff-startup.py").executable, (
"%s/bin/ff-startup.py is not executable -- anvil-core's build.sh "
"chmods +x over payload/bin, so this means it did not." % MODDIR)
# ----------------------------------------------------------- the environment
def test_anvil_env_resolves_ff_python_to_our_own_interpreter(box):
"""Sourced, not retyped: a copy of these exports in the harness is what
let this check drift away from the real ones before."""
got = box.sh(ENV + 'echo "$FF_PYTHON"')
assert got.first_line == PY, (
"anvil-env.sh set FF_PYTHON to %r, expected %s"
% (got.first_line, PY))
def test_ff_python_is_the_interpreter_that_is_there(box):
assert box.file(PY).executable, "%s is not executable" % PY
# ------------------------------------------------------- it runs on 3.13/qemu
def test_ffscreen_imports_on_the_printers_interpreter(box):
"""The whole reason this gate is on a replica: ffscreen.py's stdlib-only
imports have been checked against CPython's removed-in-3.13 list by
reading, and reading has not been enough before."""
got = box.sh(ENV + '"$FF_PYTHON" -c "%s; print(\'glyphs\', len(ffscreen.FONT))" 2>&1'
% IMPORT)
assert got.ok, "ffscreen does not import on %s: %s" % (PY, got.text)
assert "glyphs" in got.text, got.text
glyphs = int(got.text.split("glyphs")[1].split()[0])
assert glyphs > 40, "the font has only %d glyphs: %s" % (glyphs, got.text)
def test_ff_startup_runs_under_the_printers_interpreter(box):
"""--help exits before any I/O, so this is purely "does this interpreter
accept the syntax and the imports"."""
got = box.sh(ENV + '"$FF_PYTHON" %s/bin/ff-startup.py --help 2>&1' % MODDIR)
assert got.ok, "ff-startup.py --help failed: %s" % got.text
def test_ff_startup_reaches_both_of_its_siblings(box):
"""ff-startup.py imports ffscreen and ff_mcu_bringup by PLAIN NAME, which
resolves only because python puts a script's own directory on sys.path --
true when the s6 service runs it by path, false the moment anything loads
it another way. Both imports are wrapped in try/except by design, so a
failure is silent: no ffscreen means a black panel for the whole install,
and no ff_mcu_bringup means the boards are never handed over and klippy
opens the ports at bootloaders. Only the script running ITSELF can tell,
which is what --selftest is."""
got = box.sh(ENV + '"$FF_PYTHON" %s/bin/ff-startup.py --selftest 2>&1' % MODDIR)
assert got.ok, "ff-startup.py --selftest exited %d: %s" % (got.code, got.text)
lines = got.text.splitlines()
assert "ffscreen: yes" in lines, got.text
assert "ff_mcu_bringup: yes" in lines, got.text
assert "selftest: ok" in lines, got.text
def test_the_selftest_names_all_three_boards(box):
"""Three ports, each with a name of its own -- board_name() falling back
to "THE ..." is what the selftest's `named:` line reports."""
got = box.sh(ENV + '"$FF_PYTHON" %s/bin/ff-startup.py --selftest 2>&1' % MODDIR)
ports = [ln for ln in got.text.splitlines() if ln.startswith("ports: ")]
assert ports, "the selftest reported no ports at all: %s" % got.text
assert ports[0].startswith("ports: 3"), ports[0]
assert "named: yes" in got.text.splitlines(), got.text
# --------------------------------------------------------------- the frame
def test_a_frame_is_drawn(frame):
"""Everything below reads this same frame, so its failure is reported
once here rather than as eight mysterious KeyErrors."""
assert frame["_ok"], "drawing failed: %s" % frame["_text"]
assert "stride" in frame, "no stride reported: %s" % frame["_text"]
assert int(frame["stride"][0]) == W * (BPP // 8), (
"stride is %s, expected %d for %dx%d@%d"
% (frame["stride"][0], W * (BPP // 8), W, H, BPP))
def test_the_frame_is_exactly_one_screen(frame):
"""A stride or bpp mistake shows up here as a short or long file, long
before anyone sees a sheared panel. 1536000 is also start.img's own size,
which is where the geometry came from."""
assert "size" in frame, frame["_text"]
assert int(frame["size"][0]) == ONE_SCREEN, (
"the frame is %s bytes, expected %d (%dx%d at %dbpp)"
% (frame["size"][0], ONE_SCREEN, W, H, BPP))
def test_the_frame_is_rotated_for_the_panel(frame):
"""A portrait buffer means a panel mounted sideways: 480x800 stored,
800x480 drawn on. Everything above _rect works in landscape, so getting
this wrong is a frame drawn into a fifth of the screen."""
assert "rotate" in frame, frame["_text"]
assert int(frame["rotate"][0]) == 90, (
"rotate is %s, expected 90 for a %dx%d buffer"
% (frame["rotate"][0], W, H))
surface = tuple(int(n) for n in frame["surface"])
assert surface == LANDSCAPE, (
"the drawing surface is %s, expected %s" % (surface, LANDSCAPE))
def test_the_background_was_painted(frame, palette):
"""The device was truncated to zero bytes first, so a background-coloured
pixel in the top-left corner is proof that this frame was written, rather
than that the file happens to exist. The corner is also the one pixel no
text or bar can reach, so it is background or it is a bug."""
assert "corner" in frame, frame["_text"]
corner = frame["corner"][0]
assert corner == palette["background"].hex(), (
"the first pixel is %s, expected the background %s -- an all-zero "
"corner means nothing was drawn, anything else means the packing or "
"the stride is wrong" % (corner, palette["background"].hex()))
assert int(frame["background"][0]) > 0, (
"not one background pixel in the buffer: %s" % frame["_text"])
def test_the_title_reached_the_buffer(frame):
"""Text, not just a painted rectangle: the title's white has to appear
somewhere, which it only does if _text and _rect agreed on coordinates."""
assert int(frame["title"][0]) > 0, (
"no title-coloured pixel anywhere in the frame: %s" % frame["_text"])
def test_the_progress_bar_reached_the_buffer(frame):
assert int(frame["bar"][0]) > 0, (
"no progress-bar pixel anywhere in the frame, though the frame was "
"drawn with progress=0.5: %s" % frame["_text"])
def test_clear_leaves_the_panel_black(frame, box):
"""HelixScreen paints its own splash on top of whatever is there, so a
frame left behind is our text under their UI. Depends on `frame` both for
ordering -- there must be something to clear -- and because it destroys
what the tests above read."""
got = box.sh(ENV + '"$FF_PYTHON" -c "\n'
"%s\n"
"ffscreen.Screen(%r, geometry=(%d, %d, %d)).clear()\n"
"buf = open(%r, 'rb').read()\n"
"print('size', len(buf))\n"
"print('nonzero', len(buf) - buf.count(b'\\x00'))\n"
'" 2>&1' % (IMPORT, FB, W, H, BPP, FB))
assert got.ok, "clear() failed: %s" % got.text
assert "nonzero 0" in got.text.splitlines(), (
"clear() left something on the panel: %s" % got.text)
assert "size %d" % ONE_SCREEN in got.text.splitlines(), (
"clear() wrote a buffer that is not one screen: %s" % got.text)
# -------------------------------------------------------------- no panel
def test_a_missing_framebuffer_is_a_no_op_not_an_error(box):
"""This runs on EVERY first boot, and a printer with no panel -- or a
panel that stops accepting writes -- must still get its calibration. So
the absent case has to be silent, not fatal. Asked on the machine rather
than only on the host, because the failure that matters is an interpreter
raising something the host's does not."""
got = box.sh(ENV + '"$FF_PYTHON" -c "\n'
"%s\n"
"s = ffscreen.Screen('/dev/does-not-exist', geometry=(%d, %d, %d))\n"
"print('ok', s.ok)\n"
"s.show('T', 'S', 'N', 0.5)\n"
"s.clear()\n"
"print('survived')\n"
'" 2>&1' % (IMPORT, W, H, BPP))
assert got.ok, "a missing framebuffer raised: %s" % got.text
lines = got.text.splitlines()
assert "ok False" in lines, (
"Screen reported ok for a device that does not exist: %s" % got.text)
assert "survived" in lines, (
"show() or clear() did not return on a screen with no device: %s"
% got.text)