Names that say what they hold

A sweep over the payload, the build scripts and the test harness,
replacing contracted identifiers with ones that state their subject.
No behaviour changes: the boot-screen frames render byte-identical
before and after, and `make test` goes green through unpack, patch,
pack and verify.

The ones that were actively misleading:

  ff_tool_offset.py   px1/py2/px3/py4 were the four probe points, but
                      the digits are ordinal (probe order) while the
                      letters are axes -- so `px3` is the -X point, not
                      the third X. Now x_plus/y_plus/x_minus/y_minus.
                      zp/zp2 -> z_trigger/z_trigger_pass2, za ->
                      z_adjust, xbar/ybar -> centroid_x/centroid_y.
  ff_toolchange.py    off_x/off_y/off_z read as "offset off", and they
                      are two different quantities: X/Y are differences
                      against the base tool, Z is absolute. offset_*.
                      tc -> toolchanger, cur -> mounted, why -> reason.
  S60web              MR_PY/MR_MAIN/MR_TMP -> MOONRAKER_*. MR_PY in
                      particular reads as a file, not an interpreter.
  run-append.sh       MRIMP -> MOONRAKER_PREFLIGHT_FAILED. It is a
                      failure flag whose name said "import".
  bin/verify.sh       T -> WORK, SW -> SOFTWARE_TAR, CH -> CHELPER.

Left alone deliberately: fd, gcmd, cmd, rgb, and single-letter loop
variables over an obvious list. They are the vocabulary of their
domain, not contractions of it.

One collision found and fixed on the way: test/run-tests.py already
had a `fixture` (the stock package), so the moonraker one is
moonraker_fixture and the stock one is stock_package. The first pass
shadowed it and the fixture build failed -- which is the argument for
the whole change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oleksandr ShyshatskyiandClaude Opus 5 committed 2026-08-25 22:37:38 +03:00
1 parent 73d967efb2
commit 4704ca0213
28 files changed
+1199 -1097

No files matched your search

+5 -5
View File
@@ -5,8 +5,8 @@
# ./bin/build.sh --full options are passed through to pack.sh
# (--slim is accepted but is the default)
set -euo pipefail
D="$(dirname "$0")"
"$D/fetch-assets.sh"
"$D/unpack.sh"
"$D/patch.sh"
"$D/pack.sh" "$@"
SCRIPT_DIR="$(dirname "$0")"
"$SCRIPT_DIR/fetch-assets.sh"
"$SCRIPT_DIR/unpack.sh"
"$SCRIPT_DIR/patch.sh"
"$SCRIPT_DIR/pack.sh" "$@"
+57 -57
View File
@@ -4,8 +4,8 @@
set -euo pipefail
. "$(dirname "$0")/common.sh"
SW=work/software
[ -d "$SW" ] || { echo "run bin/unpack.sh first" >&2; exit 1; }
SOFTWARE_DIR=work/software
[ -d "$SOFTWARE_DIR" ] || { echo "run bin/unpack.sh first" >&2; exit 1; }
say() { printf '>> %s\n' "$*"; }
skip() { printf ' (skip) %s\n' "$*"; }
@@ -19,9 +19,9 @@ MODDIR=/usr/data/anvil
# HelixScreen are ~100MB and would overflow it. They ride in the outer package
# instead, land in /usr/data/update/ (data partition), and are moved to
# /usr/data/anvil from there.
MP=work/modpayload
rm -rf "$MP" "$SW/mod" # $SW/mod: leftover from an older layout
mkdir -p "$MP/bin" "$MP/nginx" "$MP/www" "$MP/config"
MOD_PAYLOAD=work/modpayload
rm -rf "$MOD_PAYLOAD" "$SOFTWARE_DIR/mod" # $SOFTWARE_DIR/mod: leftover from an older layout
mkdir -p "$MOD_PAYLOAD/bin" "$MOD_PAYLOAD/nginx" "$MOD_PAYLOAD/www" "$MOD_PAYLOAD/config"
# ---------------------------------------------------------------- 1. Klipper
# BUILD_KLIPPER=fork (the default) ships the creator5 Klipper tree; =stock
@@ -63,9 +63,9 @@ fork)
# exists is not trusted with the tree's freshness: one that outlived its
# sources is how a v0.12-generation binary once shipped under a v0.13
# klippy (see test/test-chelper.py's docstring).
CH="$FORK/klippy/chelper/c_helper.so"
if [ ! -f "$CH" ] || [ -n "$(find "$FORK/klippy/chelper" \
\( -name '*.c' -o -name '*.h' \) -newer "$CH" -print -quit)" ]; then
CHELPER="$FORK/klippy/chelper/c_helper.so"
if [ ! -f "$CHELPER" ] || [ -n "$(find "$FORK/klippy/chelper" \
\( -name '*.c' -o -name '*.h' \) -newer "$CHELPER" -print -quit)" ]; then
GCC="work/.mips-toolchain/mips-gcc720-glibc229/bin/mips-linux-gnu-gcc"
if [ ! -x "$GCC" ]; then
[ -f "${MIPS_TOOLCHAIN_TGZ:-}" ] || {
@@ -81,13 +81,13 @@ fork)
# printer would use if it could compile, which it cannot.
"$GCC" -Wall -g -O2 -shared -fPIC \
-flto -fwhole-program -fno-use-linker-plugin \
-o "$CH" "$FORK"/klippy/chelper/*.c
-o "$CHELPER" "$FORK"/klippy/chelper/*.c
fi
# The gates. ABI: the kernel refuses anything but MIPS32r2/nan2008/o32.
# Symbols: cffi resolves lazily, so a stale .so dies at connect on the
# printer instead of at import in the build -- catch it here.
if ! readelf -h "$CH" 2>/dev/null | grep -q nan2008; then
if ! readelf -h "$CHELPER" 2>/dev/null | grep -q nan2008; then
echo " !! c_helper.so is NOT nan2008 -- the kernel will refuse it" >&2
exit 1
fi
@@ -96,19 +96,19 @@ fork)
# Stock ships only a handful of klippy files as an overlay; the fork is a
# different Klipper generation (v0.13 vs v0.12), so ship the WHOLE tree.
rm -rf "$SW/klipper/klippy"
mkdir -p "$SW/klipper/klippy"
rm -rf "$SOFTWARE_DIR/klipper/klippy"
mkdir -p "$SOFTWARE_DIR/klipper/klippy"
( cd "$FORK/klippy" && tar -cf - \
--exclude='__pycache__' --exclude='*.pyc' --exclude='chelper/*.o' . ) \
| tar -xf - -C "$SW/klipper/klippy"
| tar -xf - -C "$SOFTWARE_DIR/klipper/klippy"
mkdir -p work/.chelper/chelper
cp -f "$CH" work/.chelper/chelper/c_helper.so
tar -cf "$SW/klipper/chelper.tar" -C work/.chelper chelper
cp -f "$CHELPER" work/.chelper/chelper/c_helper.so
tar -cf "$SOFTWARE_DIR/klipper/chelper.tar" -C work/.chelper chelper
rm -rf work/.chelper
# klippy/ now contains the fork's own extras+kinematics, so the stock
# overlay dirs would only re-inject 0.12-era files on top. Drop them.
rm -rf "$SW/klipper/extras" "$SW/klipper/kinematics"
mkdir -p "$SW/klipper/extras"
rm -rf "$SOFTWARE_DIR/klipper/extras" "$SOFTWARE_DIR/klipper/kinematics"
mkdir -p "$SOFTWARE_DIR/klipper/extras"
;;
stock)
skip "Klipper: keeping stock tree (BUILD_KLIPPER=stock)"
@@ -124,12 +124,12 @@ esac
# creator5-toolchange checkout, pointed at by TOOLCHANGE= in config.env.
if [ "${BUILD_TOOLCHANGE:-1}" = "1" ]; then
say "Toolchange: ff_*.py + configs"
mkdir -p "$SW/klipper/extras"
cp -f payload/klipper/extras/ff_*.py "$SW/klipper/extras/"
mkdir -p "$SOFTWARE_DIR/klipper/extras"
cp -f payload/klipper/extras/ff_*.py "$SOFTWARE_DIR/klipper/extras/"
# .cfg files belong on the data partition. These are mod-owned: run.sh
# overwrites them on every update (test_config_ownership.py enforces it).
# User changes go in printer.cfg, which no flash ever writes.
cp -f payload/klipper/config/ff-*.cfg "$MP/config/"
cp -f payload/klipper/config/ff-*.cfg "$MOD_PAYLOAD/config/"
# Our printer.base.cfg is FlashForge's with the chamber block replaced by
# [include printer.chamber.cfg] -- Klipper can override an option but
# cannot un-declare a section, and the plain Creator 5 has no chamber
@@ -137,7 +137,7 @@ if [ "${BUILD_TOOLCHANGE:-1}" = "1" ]; then
# NOTE: this cp is why the stock-drift check lives in bin/unpack.sh and
# not in a test -- it overwrites the pristine copy, and the test that used
# to read it afterwards was comparing our file against itself.
cp -f payload/klipper/config/printer.base.cfg "$SW/klipper/config/printer.base.cfg"
cp -f payload/klipper/config/printer.base.cfg "$SOFTWARE_DIR/klipper/config/printer.base.cfg"
# Anything that differs between models exists once per model, named
# <file>.creator5 / <file>.creator5pro, and the matching one is installed
@@ -149,15 +149,15 @@ if [ "${BUILD_TOOLCHANGE:-1}" = "1" ]; then
[ -e "$variant" ] || continue
base=$(basename "$variant" ".$SUFFIX")
case "$base" in
printer.*) dest="$SW/klipper/config/$base" ;;
*) dest="$MP/config/$base" ;;
printer.*) dest="$SOFTWARE_DIR/klipper/config/$base" ;;
*) dest="$MOD_PAYLOAD/config/$base" ;;
esac
cp -f "$variant" "$dest"
say "Model: $base for $TARGET_MACHINE"
done
# printer.base.cfg includes it unconditionally, so without it klippy will
# not start at all. A broken build, not a silent default.
[ -f "$SW/klipper/config/printer.chamber.cfg" ] \
[ -f "$SOFTWARE_DIR/klipper/config/printer.chamber.cfg" ] \
|| { echo "no printer.chamber.cfg.$SUFFIX for TARGET_MACHINE=$TARGET_MACHINE" >&2; exit 1; }
else
skip "Toolchange"
@@ -170,18 +170,18 @@ if [ "${BUILD_MAINSAIL:-1}" = "1" ]; then
# should have put it here.
[ -f "${MAINSAIL_ZIP:-}" ] || { echo "BUILD_MAINSAIL=1 but no Mainsail zip at '${MAINSAIL_ZIP:-}' -- run ./bin/fetch-assets.sh" >&2; exit 1; }
say "Mainsail: unpacking $(basename "$MAINSAIL_ZIP")"
mkdir -p "$MP/www/mainsail"
unzip -q -o "$MAINSAIL_ZIP" -d "$MP/www/mainsail"
cp -f assets/nginx.conf "$MP/nginx/nginx.conf"
du -sh "$MP/www/mainsail" | awk '{print " "$1}'
mkdir -p "$MOD_PAYLOAD/www/mainsail"
unzip -q -o "$MAINSAIL_ZIP" -d "$MOD_PAYLOAD/www/mainsail"
cp -f assets/nginx.conf "$MOD_PAYLOAD/nginx/nginx.conf"
du -sh "$MOD_PAYLOAD/www/mainsail" | awk '{print " "$1}'
else
skip "Mainsail"
fi
[ -f assets/moonraker.conf ] && cp -f assets/moonraker.conf "$MP/config/moonraker.conf"
[ -f assets/moonraker.conf ] && cp -f assets/moonraker.conf "$MOD_PAYLOAD/config/moonraker.conf"
# The user seam for Moonraker. moonraker.conf includes it by name, and
# run-append.sh creates it only when it is missing -- never overwrites it.
[ -f assets/moonraker-custom.conf ] \
&& cp -f assets/moonraker-custom.conf "$MP/config/moonraker-custom.conf"
&& cp -f assets/moonraker-custom.conf "$MOD_PAYLOAD/config/moonraker-custom.conf"
# ------------------------------------------------------------- 4. Moonraker
# WHY THIS EXISTS -- the stock Moonraker is a 2022 build (it reports API
@@ -246,17 +246,17 @@ if [ "${BUILD_MOONRAKER:-1}" = "1" ]; then
# shipping nothing here would look like a clean build and a dead UI.
[ -f work/.moonraker/moonraker/moonraker.py ] || {
echo " !! no moonraker/moonraker.py in $(basename "$MOONRAKER_TGZ")" >&2; exit 1; }
rm -rf "$MP/moonraker"
cp -a work/.moonraker/moonraker "$MP/moonraker"
rm -rf "$MOD_PAYLOAD/moonraker"
cp -a work/.moonraker/moonraker "$MOD_PAYLOAD/moonraker"
# The gate run-append.sh puts in front of the swap. It reads Moonraker's
# own component list out of the tree beside it, so it does not need
# updating when the pin moves.
cp -f payload/moonraker-preflight.py "$MP/moonraker-preflight.py"
cp -f payload/moonraker-preflight.py "$MOD_PAYLOAD/moonraker-preflight.py"
# Tests never run on the printer and are a sizeable chunk of the tree.
rm -rf "$MP/moonraker/tests"
find "$MP/moonraker" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
rm -rf "$MOD_PAYLOAD/moonraker/tests"
find "$MOD_PAYLOAD/moonraker" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
rm -rf work/.moonraker
du -sh "$MP/moonraker" | awk '{print " "$1}'
du -sh "$MOD_PAYLOAD/moonraker" | awk '{print " "$1}'
else
skip "Moonraker: keeping the stock 2022 build"
fi
@@ -265,17 +265,17 @@ fi
if [ "${BUILD_HELIX:-1}" = "1" ]; then
[ -f "${HELIX_TGZ:-}" ] || { echo "BUILD_HELIX=1 but no HelixScreen tarball at '${HELIX_TGZ:-}' -- run ./bin/fetch-assets.sh" >&2; exit 1; }
say "HelixScreen: unpacking $(basename "$HELIX_TGZ")"
mkdir -p "$MP/helixscreen"
tar -xzf "$HELIX_TGZ" -C "$MP" # yields $MP/helixscreen/
mkdir -p "$MOD_PAYLOAD/helixscreen"
tar -xzf "$HELIX_TGZ" -C "$MOD_PAYLOAD" # yields $MOD_PAYLOAD/helixscreen/
# Printer-database entry so it detects the Creator 5 Pro as a tool changer
mkdir -p "$MP/helixscreen/config/printer_database.d"
mkdir -p "$MOD_PAYLOAD/helixscreen/config/printer_database.d"
cp -f payload/helixscreen/printer_database.d/*.json \
"$MP/helixscreen/config/printer_database.d/"
"$MOD_PAYLOAD/helixscreen/config/printer_database.d/"
# Optional platform hook. No such file is in the repo, so this never fires
# on a stock checkout -- drop one in assets/ to have it shipped.
[ -f assets/hooks-creator5.sh ] && \
cp -f assets/hooks-creator5.sh "$MP/helixscreen/assets/config/platform/"
du -sh "$MP/helixscreen" | awk '{print " "$1}'
cp -f assets/hooks-creator5.sh "$MOD_PAYLOAD/helixscreen/assets/config/platform/"
du -sh "$MOD_PAYLOAD/helixscreen" | awk '{print " "$1}'
else
skip "HelixScreen"
fi
@@ -301,7 +301,7 @@ else
skip "SSH"
fi
if [ -n "${BUSYBOX_BIN:-}" ] && [ -f "$BUSYBOX_BIN" ]; then
cp -f "$BUSYBOX_BIN" "$MP/bin/busybox"; chmod +x "$MP/bin/busybox"
cp -f "$BUSYBOX_BIN" "$MOD_PAYLOAD/bin/busybox"; chmod +x "$MOD_PAYLOAD/bin/busybox"
fi
# --------------------------------------------------------- 7. root password
@@ -311,15 +311,15 @@ if [ -n "${ROOT_PW_HASH:-}" ]; then
# what dropbear reads at authentication time -- so this is the live shadow
# even though dropbear started earlier from the read-only squashfs.
awk -v h="$ROOT_PW_HASH" 'BEGIN{FS=OFS=":"} $1=="root"{$2=h} {print}' \
"$SW/shadow" > "$SW/shadow.new" && mv -f "$SW/shadow.new" "$SW/shadow"
"$SOFTWARE_DIR/shadow" > "$SOFTWARE_DIR/shadow.new" && mv -f "$SOFTWARE_DIR/shadow.new" "$SOFTWARE_DIR/shadow"
else
skip "root password (set ROOT_PW_HASH)"
fi
# ------------------------------------------------ 8. start.sh (web stack on)
say "start.sh: enabling nginx + moonraker"
cp -f payload/start.sh "$SW/start.sh"
chmod +x "$SW/start.sh"
cp -f payload/start.sh "$SOFTWARE_DIR/start.sh"
chmod +x "$SOFTWARE_DIR/start.sh"
# ------------------------------------- 9. firmwareExe -> our wrapper script
# The stock chain is rcS -> S99factory_test_shell -> app_startup.sh ->
@@ -332,20 +332,20 @@ chmod +x "$SW/start.sh"
# here could ever be a reliable backup. Flashing the stock FlashForge package
# -- which still ships the binary -- is the uninstall.
say "firmwareExe: installing wrapper (replaces the stock binary)"
cp -f payload/firmwareExe "$SW/firmwareExe"
chmod +x "$SW/firmwareExe"
cp -f payload/firmwareExe "$SOFTWARE_DIR/firmwareExe"
chmod +x "$SOFTWARE_DIR/firmwareExe"
# ----------------------------------------------------- 10. mod service dir
mkdir -p "$MP/init.d"
[ -d payload/bin ] && cp -f payload/bin/* "$MP/bin/" && chmod +x "$MP/bin"/*
cp -f payload/init.d/S* "$MP/init.d/"
chmod +x "$MP/init.d"/S*
mkdir -p "$MOD_PAYLOAD/init.d"
[ -d payload/bin ] && cp -f payload/bin/* "$MOD_PAYLOAD/bin/" && chmod +x "$MOD_PAYLOAD/bin"/*
cp -f payload/init.d/S* "$MOD_PAYLOAD/init.d/"
chmod +x "$MOD_PAYLOAD/init.d"/S*
sed -e "s/^MOD_WEB=.*/MOD_WEB=${MOD_WEB:-1}/" \
-e "s/^MOD_CAM=.*/MOD_CAM=${MOD_CAM:-1}/" \
-e "s/^MOD_UI=.*/MOD_UI=${MOD_UI:-1}/" \
-e "s/^MOD_SSH=.*/MOD_SSH=${MOD_SSH:-1}/" \
-e "s/^MOD_WIFI=.*/MOD_WIFI=${MOD_WIFI:-1}/" \
payload/anvil.conf > "$MP/anvil.conf"
payload/anvil.conf > "$MOD_PAYLOAD/anvil.conf"
# --------------------------------------------------- 11. run.sh install step
say "run.sh: injecting mod install blocks (pre + post)"
@@ -361,7 +361,7 @@ else
fi
sed -e "s/^MOD_PW_AUTO=.*/MOD_PW_AUTO=$PW_AUTO/" \
payload/run-append.sh > "$POST"
python3 - "$SW/run.sh" payload/run-pre.sh "$POST" <<'PY'
python3 - "$SOFTWARE_DIR/run.sh" payload/run-pre.sh "$POST" <<'PY'
import sys, re
run, pre_f, post_f = sys.argv[1], sys.argv[2], sys.argv[3]
B1, E1 = "# >>> anvil pre >>>", "# <<< anvil pre <<<"
@@ -389,11 +389,11 @@ src = src[:j] + post + src[j:]
print(" post-block inserted before exit")
open(run, 'w', encoding='utf-8', errors='surrogateescape').write(src)
PY
chmod +x "$SW/run.sh"
chmod +x "$SOFTWARE_DIR/run.sh"
rm -f "$POST"
echo
echo "Patched."
echo " software component: $(du -sh "$SW" | cut -f1) (-> /usr/prog, firmware partition)"
echo " mod payload: $(du -sh "$MP" | cut -f1) (-> /usr/data/anvil, data partition)"
echo " software component: $(du -sh "$SOFTWARE_DIR" | cut -f1) (-> /usr/prog, firmware partition)"
echo " mod payload: $(du -sh "$MOD_PAYLOAD" | cut -f1) (-> /usr/data/anvil, data partition)"
echo "Now run ./bin/pack.sh"
+28 -24
View File
@@ -66,24 +66,24 @@ LOGFILE = '/USR/DATA/LOGS/ANVIL-BOOT.LOG'
def load_ffscreen():
path = os.path.join(ROOT, 'payload', 'bin', 'ffscreen.py')
spec = importlib.util.spec_from_file_location('ffscreen', path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def png(path, width, height, rgb_rows):
def chunk(tag, data):
body = tag + data
return (struct.pack('>I', len(data)) + body
+ struct.pack('>I', zlib.crc32(body)))
tagged = tag + data
return (struct.pack('>I', len(data)) + tagged
+ struct.pack('>I', zlib.crc32(tagged)))
# One filter byte (0 = none) per scanline, then the raw RGB triples.
raw = b''.join(b'\x00' + row for row in rgb_rows)
scanlines = b''.join(b'\x00' + row for row in rgb_rows)
with open(path, 'wb') as fh:
fh.write(b'\x89PNG\r\n\x1a\n')
fh.write(chunk(b'IHDR', struct.pack('>IIBBBBB', width, height,
8, 2, 0, 0, 0)))
fh.write(chunk(b'IDAT', zlib.compress(raw, 6)))
fh.write(chunk(b'IDAT', zlib.compress(scanlines, 6)))
fh.write(chunk(b'IEND', b''))
@@ -93,17 +93,17 @@ def to_rows(buf, screen):
The buffer is read in its own orientation and then turned by whatever
the panel turns it by, so the PNG matches the screen and not the memory.
"""
step = screen.bpp // 8
bytes_per_pixel = screen.bpp // 8
def pixel(bx, by):
off = by * screen.stride + bx * step
px = buf[off:off + step]
if step == 4:
return bytes((px[2], px[1], px[0])) # B,G,R,X on the wire
v = px[0] | (px[1] << 8) # RGB565
return bytes((((v >> 11) & 0x1F) << 3,
((v >> 5) & 0x3F) << 2,
(v & 0x1F) << 3))
def pixel(buf_x, buf_y):
offset = buf_y * screen.stride + buf_x * bytes_per_pixel
raw = buf[offset:offset + bytes_per_pixel]
if bytes_per_pixel == 4:
return bytes((raw[2], raw[1], raw[0])) # B,G,R,X on the wire
packed = raw[0] | (raw[1] << 8) # RGB565
return bytes((((packed >> 11) & 0x1F) << 3,
((packed >> 5) & 0x3F) << 2,
(packed & 0x1F) << 3))
# The inverse of the mapping in ffscreen._rect.
rows = []
@@ -121,12 +121,16 @@ def to_rows(buf, screen):
def main(argv):
ap = argparse.ArgumentParser(description='render the first-boot screen')
ap.add_argument('--out', default=os.path.join(ROOT, 'work', 'boot-screen'))
ap.add_argument('--size', default='480x800@32',
help='the FRAMEBUFFER, not the panel (default: the real one)')
ap.add_argument('--rotate', type=int, default=None, choices=[0, 90, 270])
args = ap.parse_args(argv)
parser = argparse.ArgumentParser(
description='render the first-boot screen')
parser.add_argument('--out',
default=os.path.join(ROOT, 'work', 'boot-screen'))
parser.add_argument(
'--size', default='480x800@32',
help='the FRAMEBUFFER, not the panel (default: the real one)')
parser.add_argument('--rotate', type=int, default=None,
choices=[0, 90, 270])
args = parser.parse_args(argv)
ffscreen = load_ffscreen()
geometry = ffscreen.parse_geometry(args.size)
+48 -48
View File
@@ -16,25 +16,25 @@ ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; }
bad() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; FAIL=1; }
warn() { printf ' \033[33mWARN\033[0m %s\n' "$*"; }
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
# 1 ---------------------------------------------------------------- decrypt
if openssl des3 -d -k "$FF_KEY" -salt -md md5 -in "$PKG" 2>/dev/null \
| tar -xf - -C "$T" 2>/dev/null; then
| tar -xf - -C "$WORK" 2>/dev/null; then
ok "decrypts with the firmware key and is a valid tar"
else
bad "decrypt/untar failed -- the printer's unTar will reject this"; exit 1
fi
# 2 ------------------------------------------------------- installer present
if [ -f "$T/runFirmwareExe.sh" ]; then
if [ -f "$WORK/runFirmwareExe.sh" ]; then
ok "runFirmwareExe.sh present"
else
bad "no runFirmwareExe.sh -- app_startup.sh falls back to plain tar and gives up"
fi
# 3 ----------------------------------------- components are PLAIN tar, not xz
for f in "$T"/software-*.tar.xz "$T"/kernel-*.tar.xz "$T"/control-*.tar.xz "$T"/library-*.tar.xz; do
for f in "$WORK"/software-*.tar.xz "$WORK"/kernel-*.tar.xz "$WORK"/control-*.tar.xz "$WORK"/library-*.tar.xz; do
[ -f "$f" ] || continue
b=$(basename "$f")
if tar -tf "$f" >/dev/null 2>&1; then
@@ -45,38 +45,38 @@ for f in "$T"/software-*.tar.xz "$T"/kernel-*.tar.xz "$T"/control-*.tar.xz "$T"/
done
# 4 ------------------------------------------------------------- md5sum gate
SW=$(ls -1 "$T"/software-*.tar.xz 2>/dev/null | head -n1)
if [ -n "$SW" ]; then
mkdir -p "$T/sw" && tar -xf "$SW" -C "$T/sw"
if [ -f "$T/sw/md5sum.list" ]; then
SOFTWARE_TAR=$(ls -1 "$WORK"/software-*.tar.xz 2>/dev/null | head -n1)
if [ -n "$SOFTWARE_TAR" ]; then
mkdir -p "$WORK/sw" && tar -xf "$SOFTWARE_TAR" -C "$WORK/sw"
if [ -f "$WORK/sw/md5sum.list" ]; then
# NB: the printer's busybox md5sum takes -s for quiet; GNU coreutils
# spells it --status. Use whichever this host has.
if ( cd "$T/sw" && md5sum --status -c md5sum.list 2>/dev/null ) \
|| ( cd "$T/sw" && md5sum -s -c md5sum.list 2>/dev/null ); then
ok "md5sum.list verifies ($(wc -l < "$T/sw/md5sum.list") entries)"
if ( cd "$WORK/sw" && md5sum --status -c md5sum.list 2>/dev/null ) \
|| ( cd "$WORK/sw" && md5sum -s -c md5sum.list 2>/dev/null ); then
ok "md5sum.list verifies ($(wc -l < "$WORK/sw/md5sum.list") entries)"
else
bad "md5sum.list does NOT verify -- installer aborts and deletes the payload"
( cd "$T/sw" && md5sum -c md5sum.list 2>&1 | grep -v ': OK$' | head -5 | sed 's/^/ /' )
( cd "$WORK/sw" && md5sum -c md5sum.list 2>&1 | grep -v ': OK$' | head -5 | sed 's/^/ /' )
fi
grep -q 'md5sum.list' "$T/sw/md5sum.list" && bad "md5sum.list lists itself (can never match)"
grep -q 'md5sum.list' "$WORK/sw/md5sum.list" && bad "md5sum.list lists itself (can never match)"
else
bad "no md5sum.list in the software component"
fi
fi
# 5 -------------------------------------------------- shell syntax of scripts
for s in "$T/runFirmwareExe.sh" "$T/sw/run.sh" "$T/sw/start.sh" "$T/sw/app_startup.sh"; do
for s in "$WORK/runFirmwareExe.sh" "$WORK/sw/run.sh" "$WORK/sw/start.sh" "$WORK/sw/app_startup.sh"; do
[ -f "$s" ] || continue
if sh -n "$s" 2>/dev/null; then ok "syntax OK: $(basename "$s")"
else bad "SYNTAX ERROR in $(basename "$s")"; sh -n "$s" 2>&1 | head -3 | sed 's/^/ /'; fi
done
# 6 ---------------------------------------------- boot chain actually hooks up
if [ -f "$T/sw/app_startup.sh" ]; then
grep -q 'gt9xx_touch.ko' "$T/sw/app_startup.sh" \
if [ -f "$WORK/sw/app_startup.sh" ]; then
grep -q 'gt9xx_touch.ko' "$WORK/sw/app_startup.sh" \
&& ok "touchscreen driver still loaded at boot" \
|| bad "gt9xx_touch.ko insmod missing -- no touchscreen"
grep -q 'firmwareExe' "$T/sw/app_startup.sh" \
grep -q 'firmwareExe' "$WORK/sw/app_startup.sh" \
&& ok "a UI is launched at boot" \
|| bad "nothing launches a UI -- printer boots to a blank screen"
fi
@@ -86,23 +86,23 @@ fi
# the v20260824 release passed here precisely because everything below was
# conditional on files that the broken build simply did not contain.
if [ "${BUILD_KLIPPER:-fork}" = "fork" ]; then
if [ -f "$T/sw/klipper/klippy/chelper/__init__.py" ]; then
if [ -f "$WORK/sw/klipper/klippy/chelper/__init__.py" ]; then
ok "package carries the fork klippy tree"
else
bad "BUILD_KLIPPER=fork but no klippy tree in the package -- this is the stock-overlay build that shipped as v20260824"
fi
[ -f "$T/sw/klipper/chelper.tar" ] \
[ -f "$WORK/sw/klipper/chelper.tar" ] \
|| bad "BUILD_KLIPPER=fork but no chelper.tar in the package"
fi
if [ -f "$T/sw/klipper/chelper.tar" ]; then
mkdir -p "$T/ch" && tar -xf "$T/sw/klipper/chelper.tar" -C "$T/ch"
CH=$(find "$T/ch" -name 'c_helper.so' | head -n1)
if [ -n "$CH" ] && ! head -c 4 "$CH" | grep -q ELF; then
if [ -f "$WORK/sw/klipper/chelper.tar" ]; then
mkdir -p "$WORK/ch" && tar -xf "$WORK/sw/klipper/chelper.tar" -C "$WORK/ch"
CHELPER=$(find "$WORK/ch" -name 'c_helper.so' | head -n1)
if [ -n "$CHELPER" ] && ! head -c 4 "$CHELPER" | grep -q ELF; then
warn "c_helper.so is not an ELF (synthetic fixture) -- ABI check skipped"
CH=""
CHELPER=""
fi
if [ -n "$CH" ]; then
if readelf -h "$CH" 2>/dev/null | grep -q nan2008; then
if [ -n "$CHELPER" ]; then
if readelf -h "$CHELPER" 2>/dev/null | grep -q nan2008; then
ok "c_helper.so is nan2008 MIPS32r2 (kernel will load it)"
else
bad "c_helper.so is NOT nan2008 -- kernel returns ENOEXEC, klippy dies"
@@ -110,8 +110,8 @@ if [ -f "$T/sw/klipper/chelper.tar" ]; then
# The failure mode that reached a printer: a .so from older sources
# than the klippy tree beside it. cffi resolves lazily, so only a
# symbol-table check catches it before the machine does.
if [ -f "$T/sw/klipper/klippy/chelper/__init__.py" ]; then
if python3 "$ROOT/test/test-chelper.py" "$T/sw/klipper" >/dev/null 2>&1; then
if [ -f "$WORK/sw/klipper/klippy/chelper/__init__.py" ]; then
if python3 "$ROOT/test/test-chelper.py" "$WORK/sw/klipper" >/dev/null 2>&1; then
ok "c_helper.so exports everything the shipped klippy declares"
else
bad "c_helper.so does not match the shipped klippy tree (stale build)"
@@ -121,12 +121,12 @@ if [ -f "$T/sw/klipper/chelper.tar" ]; then
fi
# 8 ------------------------------------------------------------ mod payload
if [ -f "$T/anvil.tar.xz" ]; then
if xz -t "$T/anvil.tar.xz" 2>/dev/null; then ok "anvil.tar.xz is valid xz"
elif tar -tf "$T/anvil.tar.xz" >/dev/null 2>&1; then ok "anvil.tar.xz is a plain tar"
if [ -f "$WORK/anvil.tar.xz" ]; then
if xz -t "$WORK/anvil.tar.xz" 2>/dev/null; then ok "anvil.tar.xz is valid xz"
elif tar -tf "$WORK/anvil.tar.xz" >/dev/null 2>&1; then ok "anvil.tar.xz is a plain tar"
else bad "anvil.tar.xz is neither valid xz nor tar"; fi
LIST=$(xz -dc "$T/anvil.tar.xz" 2>/dev/null | tar -tf - 2>/dev/null || tar -tf "$T/anvil.tar.xz" 2>/dev/null)
LIST=$(xz -dc "$WORK/anvil.tar.xz" 2>/dev/null | tar -tf - 2>/dev/null || tar -tf "$WORK/anvil.tar.xz" 2>/dev/null)
# Herestrings, NOT `echo "$LIST" | grep -q`: under pipefail, grep -q
# exiting at an early match SIGPIPEs the echo and the pipeline returns
# 141 -- a race that stayed invisible until the fork klippy tree tripled
@@ -158,8 +158,8 @@ fi
# The most easily missed failure: a package built from the wrong model's
# firmware refuses to install, and a package built from the RIGHT model but
# flashed to the wrong one would install the wrong binaries.
PKG_MACHINE=$(sed -n 's/^MACHINE=//p' "$T/runFirmwareExe.sh" 2>/dev/null | head -n1)
PKG_PID=$(sed -n 's/^PID=//p' "$T/runFirmwareExe.sh" 2>/dev/null | head -n1)
PKG_MACHINE=$(sed -n 's/^MACHINE=//p' "$WORK/runFirmwareExe.sh" 2>/dev/null | head -n1)
PKG_PID=$(sed -n 's/^PID=//p' "$WORK/runFirmwareExe.sh" 2>/dev/null | head -n1)
if [ -n "$PKG_MACHINE" ]; then
echo " package installs on: $PKG_MACHINE (PID $PKG_PID)"
if [ -n "${TARGET_MACHINE:-}" ]; then
@@ -182,11 +182,11 @@ fi
# it: the Pro looks for /mnt/Creator5Pro-*.tgz, the non-Pro for
# /mnt/Creator5-*.tgz. It must ALSO match the gate inside, or the printer
# picks the file up and then refuses it.
BN=$(basename "$PKG")
case "$BN" in
PKG_NAME=$(basename "$PKG")
case "$PKG_NAME" in
Creator5Pro-*.tgz) FN_MACHINE=Creator5Pro ;;
Creator5-*.tgz) FN_MACHINE=Creator5 ;;
*) FN_MACHINE=""; bad "filename '$BN' matches no glob -- app_startup.sh will ignore it" ;;
*) FN_MACHINE=""; bad "filename '$PKG_NAME' matches no glob -- app_startup.sh will ignore it" ;;
esac
if [ -n "$FN_MACHINE" ]; then
if [ -z "$PKG_MACHINE" ] || [ "$FN_MACHINE" = "$PKG_MACHINE" ]; then
@@ -204,24 +204,24 @@ fi
# It compares by CONTENT, not by name: a name blacklist would trip over any
# file Mainsail or HelixScreen happens to call common.sh, while a byte-for-byte
# match against a host-lane file is exactly the thing we are trying to catch.
mkdir -p "$T/ship"
tar -xf "$T"/software-*.tar.xz -C "$T/ship" 2>/dev/null || true
if [ -f "$T/anvil.tar.xz" ]; then
mkdir -p "$T/ship/.anvil"
xz -dc "$T/anvil.tar.xz" 2>/dev/null | tar -xf - -C "$T/ship/.anvil" 2>/dev/null \
|| tar -xf "$T/anvil.tar.xz" -C "$T/ship/.anvil" 2>/dev/null || true
mkdir -p "$WORK/ship"
tar -xf "$WORK"/software-*.tar.xz -C "$WORK/ship" 2>/dev/null || true
if [ -f "$WORK/anvil.tar.xz" ]; then
mkdir -p "$WORK/ship/.anvil"
xz -dc "$WORK/anvil.tar.xz" 2>/dev/null | tar -xf - -C "$WORK/ship/.anvil" 2>/dev/null \
|| tar -xf "$WORK/anvil.tar.xz" -C "$WORK/ship/.anvil" 2>/dev/null || true
fi
find bin test docker -type f 2>/dev/null | xargs -r md5sum 2>/dev/null \
| awk '{print $1}' | sort -u > "$T/host.md5"
find "$T/ship" -type f 2>/dev/null | xargs -r md5sum 2>/dev/null > "$T/ship.md5"
LEAK=$(awk 'NR==FNR{h[$1];next} $1 in h {print}' "$T/host.md5" "$T/ship.md5" \
| sed "s|$T/ship/||")
| awk '{print $1}' | sort -u > "$WORK/host.md5"
find "$WORK/ship" -type f 2>/dev/null | xargs -r md5sum 2>/dev/null > "$WORK/ship.md5"
LEAK=$(awk 'NR==FNR{h[$1];next} $1 in h {print}' "$WORK/host.md5" "$WORK/ship.md5" \
| sed "s|$WORK/ship/||")
# Those finds are RELATIVE, so from any cwd but the repo root host.md5 comes
# out empty, the join matches nothing and this reports ok having compared
# nothing -- retiring the only check that keeps config.env and the host tooling
# off a USB stick. common.sh cds to the root, so this is a guard against a
# future caller that does not.
if [ ! -s "$T/host.md5" ]; then
if [ ! -s "$WORK/host.md5" ]; then
bad "ship-boundary check compared nothing (no files found under bin/, test/, docker/ -- wrong cwd?)"
elif [ -z "$LEAK" ]; then
ok "package carries nothing from bin/, test/ or docker/"
+107 -102
View File
@@ -204,17 +204,17 @@ class Moonraker:
def __init__(self, base):
self.base = base.rstrip('/')
def _open(self, req, timeout):
return urllib.request.urlopen(req, timeout=timeout)
def _open(self, request, timeout):
return urllib.request.urlopen(request, timeout=timeout)
def get(self, path, timeout=5.0):
"""Return an endpoint's "result" object, or None if it is not there
yet. Every failure mode -- connection refused, a timeout, an HTTP
error, a body that is not JSON -- means the same thing to us (not
ready), so they collapse into one None."""
req = urllib.request.Request(self.base + path, method='GET')
request = urllib.request.Request(self.base + path, method='GET')
try:
with self._open(req, timeout) as fh:
with self._open(request, timeout) as fh:
body = fh.read()
except Exception:
return None
@@ -230,12 +230,12 @@ class Moonraker:
request open, so a timeout or a dropped connection here is not an
error -- it is the expected shape of success. The caller decides;
we only report what happened."""
data = json.dumps({'script': script}).encode('utf-8')
req = urllib.request.Request(
self.base + '/printer/gcode/script', data=data,
payload = json.dumps({'script': script}).encode('utf-8')
request = urllib.request.Request(
self.base + '/printer/gcode/script', data=payload,
headers={'Content-Type': 'application/json'}, method='POST')
try:
with self._open(req, timeout) as fh:
with self._open(request, timeout) as fh:
fh.read()
return True, 'ok'
except urllib.error.HTTPError as exc:
@@ -253,30 +253,33 @@ class Moonraker:
# -- the two questions we ask about klipper's state --------------------
def klippy_state(self):
info = self.get('/server/info')
if info is None:
server_info = self.get('/server/info')
if server_info is None:
return None
return info.get('klippy_state')
return server_info.get('klippy_state')
def tools(self):
"""[ff_tool n] status for every tool, as a list of dicts (or None)."""
query = '&'.join('ff_tool%%20%d' % n for n in range(EXTRUDER_COUNT))
res = self.get('/printer/objects/query?' + query)
if res is None:
query = '&'.join('ff_tool%%20%d' % index
for index in range(EXTRUDER_COUNT))
result = self.get('/printer/objects/query?' + query)
if result is None:
return None
status = res.get('status', {})
return [status.get('ff_tool %d' % n) for n in range(EXTRUDER_COUNT)]
status = result.get('status', {})
return [status.get('ff_tool %d' % index)
for index in range(EXTRUDER_COUNT)]
def save_pending(self):
res = self.get('/printer/objects/query?configfile=save_config_pending')
if res is None:
result = self.get(
'/printer/objects/query?configfile=save_config_pending')
if result is None:
return None
cf = res.get('status', {}).get('configfile', {})
return bool(cf.get('save_config_pending'))
configfile = result.get('status', {}).get('configfile', {})
return bool(configfile.get('save_config_pending'))
def any_calibrated(tools):
return any(t and t.get('calibrated') for t in tools or [])
return any(tool and tool.get('calibrated') for tool in tools or [])
def board_name(key):
@@ -294,20 +297,20 @@ def hand_over_boards(panel, timeout):
"""
if bringup is None:
return
def progress(devs):
if not devs:
def progress(devices):
if not devices:
return
names = [board_name(d) for d in devs]
names = [board_name(device) for device in devices]
panel.say('WAKING ' + ' AND '.join(names[:2]), 0.08)
panel.say('WAKING THE TOOLHEAD BOARDS', 0.05)
try:
ok = bringup.bringup(list(bringup.DEFAULT_PORTS), timeout,
on_progress=progress)
all_awake = bringup.bringup(list(bringup.DEFAULT_PORTS), timeout,
on_progress=progress)
except Exception as exc:
# A board is klippy's problem to report; ours is to not die here.
log('mcu bring-up raised (%s) -- going on to klipper' % exc)
return
log('mcu bring-up %s' % ('ok' if ok else 'reported a problem'))
log('mcu bring-up %s' % ('ok' if all_awake else 'reported a problem'))
# --------------------------------------------------------------- klipper
@@ -318,7 +321,7 @@ def klippy_running():
if os.path.exists(UDS):
return True
try:
pids = [p for p in os.listdir('/proc') if p.isdigit()]
pids = [entry for entry in os.listdir('/proc') if entry.isdigit()]
except OSError:
return False
for pid in pids:
@@ -344,16 +347,17 @@ def start_klipper(args):
env['FF_SKIP_MCU_BRINGUP'] = '1'
log('starting klipper (%s)' % args.start_sh)
try:
proc = subprocess.run(['/bin/sh', args.start_sh], env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=120)
completed = subprocess.run(['/bin/sh', args.start_sh], env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, timeout=120)
except subprocess.TimeoutExpired:
log('start.sh did not return within 120s')
return False
except Exception as exc:
log('start.sh could not be run (%s)' % exc)
return False
for line in (proc.stdout or b'').decode('utf-8', 'replace').splitlines():
for line in (completed.stdout
or b'').decode('utf-8', 'replace').splitlines():
log(' start.sh: %s' % line)
return True
@@ -382,29 +386,29 @@ def stop_klipper(args):
pass
def klippy_fault(mr):
def klippy_fault(moonraker):
"""The board klippy is complaining about, if it names one.
On a failed connect klippy's state_message is "MCU 'eheaterboard' error
during connect ..." -- the only place the guilty board is named, and
exactly what an owner needs to know."""
info = mr.get('/printer/info')
if not info:
printer_info = moonraker.get('/printer/info')
if not printer_info:
return None
message = info.get('state_message') or ''
message = printer_info.get('state_message') or ''
for key in BOARDS:
if "'%s'" % key in message:
return board_name(key)
return None
def report_stack_failure(mr, panel):
def report_stack_failure(moonraker, panel):
"""The one frame someone is left looking at, naming what did not come up."""
state = mr.klippy_state()
state = moonraker.klippy_state()
if state is None:
panel.failed('MOONRAKER IS NOT RESPONDING')
elif state == 'error':
board = klippy_fault(mr)
board = klippy_fault(moonraker)
if board:
panel.failed('%s DID NOT ANSWER' % board)
else:
@@ -414,24 +418,24 @@ def report_stack_failure(mr, panel):
% str(state).upper())
def wait_for_stack(mr, panel, deadline, started, quiet=False):
def wait_for_stack(moonraker, panel, deadline, started, quiet=False):
"""Block until klipper and moonraker are both up, or time runs out.
Reports their state once per change so the boot log says which one
everybody is waiting on, and creeps the panel's bar along with the wait
so a long MCU hunt still looks like progress rather than a freeze."""
last = None
span = max(1.0, deadline - started)
last_state = None
window = max(1.0, deadline - started)
while True:
state = mr.klippy_state()
if state != last:
state = moonraker.klippy_state()
if state != last_state:
log('waiting: moonraker=%s klipper=%s'
% ('up' if state is not None else 'down', state or 'unknown'))
last = state
last_state = state
elapsed_fraction = 0.3 * (time.time() - started) / window
if state is None:
panel.say('STARTING SERVICES', 0.05 + 0.3 * (time.time() - started) / span)
panel.say('STARTING SERVICES', 0.05 + elapsed_fraction)
elif state != 'ready':
panel.say('WAITING FOR THE PRINTER',
0.05 + 0.3 * (time.time() - started) / span)
panel.say('WAITING FOR THE PRINTER', 0.05 + elapsed_fraction)
if state == 'ready':
return True
if state == 'error' and quiet:
@@ -448,16 +452,16 @@ def wait_for_stack(mr, panel, deadline, started, quiet=False):
# replaced two seconds later by another attempt reads as a
# failure the printer then ignored.
if not quiet:
report_stack_failure(mr, panel)
report_stack_failure(moonraker, panel)
return False
time.sleep(2.0)
def wait_for_ready(mr, deadline):
def wait_for_ready(moonraker, deadline):
"""Wait for klippy alone -- used after the SAVE_CONFIG restart, where
moonraker never went away and mainsail was never in question."""
while True:
if mr.klippy_state() == 'ready':
if moonraker.klippy_state() == 'ready':
return True
if time.time() >= deadline:
return False
@@ -485,17 +489,17 @@ def stamp(path, note):
def run(args):
started = time.time()
deadline = started + args.timeout
mr = Moonraker(args.moonraker)
moonraker = Moonraker(args.moonraker)
panel = Panel(args.fb, args.fb_geometry, enabled=not args.no_screen)
panel.say('STARTING SERVICES', 0.05)
try:
return startup(args, mr, panel, started, deadline)
return startup(args, moonraker, panel, started, deadline)
finally:
# Whatever happened, hand HelixScreen a clean panel.
panel.done()
def bring_up_printer(args, mr, panel, started, deadline):
def bring_up_printer(args, moonraker, panel, started, deadline):
"""Boards out of their bootloaders, klippy up, and retry until it is.
This is what init.d/S70klipper's supervise() used to do by tailing
@@ -510,27 +514,27 @@ def bring_up_printer(args, mr, panel, started, deadline):
if not args.no_klipper and not start_klipper(args):
panel.failed('KLIPPER COULD NOT BE STARTED')
return False
if wait_for_stack(mr, panel, deadline, started, quiet=True):
if wait_for_stack(moonraker, panel, deadline, started, quiet=True):
return True
if time.time() >= deadline or attempt == args.klipper_tries:
break
# Reopening the port toggles DTR/RTS, which is what gives a board
# that missed its window another chance -- so the restart is the
# fix, not merely another go at the same thing.
board = klippy_fault(mr)
board = klippy_fault(moonraker)
log('attempt %d/%d failed (%s) -- restarting klipper'
% (attempt, args.klipper_tries, board or 'no board named'))
panel.say('RETRYING %s' % (board or 'THE PRINTER'), 0.15,
detail='ATTEMPT %d OF %d' % (attempt + 1,
args.klipper_tries))
stop_klipper(args)
report_stack_failure(mr, panel)
report_stack_failure(moonraker, panel)
return False
def startup(args, mr, panel, started, deadline):
def startup(args, moonraker, panel, started, deadline):
# -- every boot ------------------------------------------------------
if not bring_up_printer(args, mr, panel, started, deadline):
if not bring_up_printer(args, moonraker, panel, started, deadline):
return 1
# -- first boot only -------------------------------------------------
@@ -546,12 +550,12 @@ def startup(args, mr, panel, started, deadline):
return 0
log('first boot: importing this unit\'s factory calibration from %s'
% args.dir)
return migrate(args, mr, panel)
return migrate(args, moonraker, panel)
def migrate(args, mr, panel):
def migrate(args, moonraker, panel):
deadline = time.time() + args.timeout
tools = mr.tools()
tools = moonraker.tools()
if tools is None:
log('could not read the ff_tool objects -- no stamp')
panel.failed('COULD NOT READ THE TOOL SETTINGS')
@@ -564,7 +568,7 @@ def migrate(args, mr, panel):
stamp(args.stamp, 'already calibrated; nothing imported')
return 0
pending = mr.save_pending()
pending = moonraker.save_pending()
if pending is None:
log('could not read save_config_pending -- no stamp')
panel.failed('COULD NOT READ THE PRINTER CONFIGURATION')
@@ -581,12 +585,12 @@ def migrate(args, mr, panel):
return 0
panel.say('READING FACTORY CALIBRATION', 0.5)
ok, detail = mr.gcode('FF_IMPORT_FIRMWARE_CONFIG')
if not ok:
applied, detail = moonraker.gcode('FF_IMPORT_FIRMWARE_CONFIG')
if not applied:
log('FF_IMPORT_FIRMWARE_CONFIG failed: %s' % detail)
panel.failed('THE PRINTER REFUSED FF_IMPORT_FIRMWARE_CONFIG')
return 1
tools = mr.tools()
tools = moonraker.tools()
if not any_calibrated(tools):
# The command ran but the JSON held nothing usable. Saving now would
# persist nothing and restart klippy for no reason.
@@ -596,17 +600,17 @@ def migrate(args, mr, panel):
log('imported; persisting with SAVE_CONFIG -- klipper restarts once')
panel.say('SAVING CALIBRATION', 0.7)
ok, detail = mr.gcode('SAVE_CONFIG', timeout=20.0)
if not ok:
applied, detail = moonraker.gcode('SAVE_CONFIG', timeout=20.0)
if not applied:
# Expected: the restart cuts the connection before moonraker answers.
log('SAVE_CONFIG did not answer (%s) -- klipper is restarting' % detail)
panel.say('RESTARTING THE PRINTER', 0.85)
if not wait_for_ready(mr, max(deadline, time.time() + 90.0)):
if not wait_for_ready(moonraker, max(deadline, time.time() + 90.0)):
log('klipper did not come back after SAVE_CONFIG -- no stamp')
panel.failed('KLIPPER DID NOT RESTART AFTER SAVING')
return 1
tools = mr.tools()
tools = moonraker.tools()
if not any_calibrated(tools):
log('klipper came back with no saved nozzle position -- the save did'
' not take, no stamp')
@@ -629,50 +633,51 @@ def selftest():
the script running itself, which is what this is for -- the replica gate
calls it, and it is worth having on a printer over ssh too.
"""
ok = True
passed = True
print('ffscreen: %s' % ('yes' if ffscreen is not None else 'NO'))
if ffscreen is None:
ok = False
passed = False
print('ff_mcu_bringup: %s' % ('yes' if bringup is not None else 'NO'))
if bringup is None:
ok = False
passed = False
else:
ports = list(bringup.DEFAULT_PORTS)
print('ports: %d (%s)' % (len(ports), ' '.join(ports)))
unnamed = [d for d in ports if not board_name(d).startswith('THE ')]
unnamed = [port for port in ports
if not board_name(port).startswith('THE ')]
print('named: %s' % ('yes' if not unnamed else 'NO %s' % unnamed))
if unnamed:
ok = False
print('selftest: %s' % ('ok' if ok else 'FAILED'))
return 0 if ok else 1
passed = False
print('selftest: %s' % ('ok' if passed else 'FAILED'))
return 0 if passed else 1
def main(argv):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--stamp', default=STAMP)
ap.add_argument('--dir', default=JSON_DIR)
ap.add_argument('--moonraker', default=MOONRAKER)
ap.add_argument('--timeout', type=float, default=TIMEOUT)
ap.add_argument('--mcu-timeout', type=float, default=MCU_TIMEOUT,
help="how long one bring-up pass may take")
ap.add_argument('--klipper-tries', type=int, default=KLIPPER_TRIES,
help="how many times to hand the boards over and retry")
ap.add_argument('--start-sh', default=START_SH)
ap.add_argument('--daemon', default=DAEMON)
ap.add_argument('--no-klipper', action='store_true',
help="do not start klipper; only wait for it")
ap.add_argument('--no-import', action='store_true',
help="only bring the printer up; never migrate")
ap.add_argument('--fb', default=None,
help='framebuffer to draw the boot screen on')
ap.add_argument('--fb-geometry', default=None,
help="panel size as WxH@BPP, skipping the sysfs probe")
ap.add_argument('--no-screen', action='store_true',
help='do not draw anything on the panel')
ap.add_argument('--dry-run', action='store_true')
ap.add_argument('--selftest', action='store_true',
help='report what this script can reach, and exit')
args = ap.parse_args(argv)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--stamp', default=STAMP)
parser.add_argument('--dir', default=JSON_DIR)
parser.add_argument('--moonraker', default=MOONRAKER)
parser.add_argument('--timeout', type=float, default=TIMEOUT)
parser.add_argument('--mcu-timeout', type=float, default=MCU_TIMEOUT,
help="how long one bring-up pass may take")
parser.add_argument('--klipper-tries', type=int, default=KLIPPER_TRIES,
help="how many times to hand the boards over and retry")
parser.add_argument('--start-sh', default=START_SH)
parser.add_argument('--daemon', default=DAEMON)
parser.add_argument('--no-klipper', action='store_true',
help="do not start klipper; only wait for it")
parser.add_argument('--no-import', action='store_true',
help="only bring the printer up; never migrate")
parser.add_argument('--fb', default=None,
help='framebuffer to draw the boot screen on')
parser.add_argument('--fb-geometry', default=None,
help="panel size as WxH@BPP, skipping the sysfs probe")
parser.add_argument('--no-screen', action='store_true',
help='do not draw anything on the panel')
parser.add_argument('--dry-run', action='store_true')
parser.add_argument('--selftest', action='store_true',
help='report what this script can reach, and exit')
args = parser.parse_args(argv)
if args.selftest:
return selftest()
try:
+47 -45
View File
@@ -102,7 +102,8 @@ def configure(fd):
VMIN=0 with VTIME=1 is what makes every read return after 0.1s whether or
not the board said anything, which is what paces the poll below.
"""
iflag, oflag, cflag, lflag, _ispeed, _ospeed, cc = termios.tcgetattr(fd)
(iflag, oflag, cflag, lflag,
_ispeed, _ospeed, control_chars) = termios.tcgetattr(fd)
iflag &= ~(termios.IGNBRK | termios.BRKINT | termios.PARMRK
| termios.ISTRIP | termios.INLCR | termios.IGNCR
@@ -117,26 +118,26 @@ def configure(fd):
# just started.
cflag &= ~termios.HUPCL
cc = list(cc)
cc[termios.VMIN] = 0
cc[termios.VTIME] = 1
control_chars = list(control_chars)
control_chars[termios.VMIN] = 0
control_chars[termios.VTIME] = 1
termios.tcsetattr(fd, termios.TCSANOW,
[iflag, oflag, cflag, lflag,
termios.B115200, termios.B115200, cc])
termios.B115200, termios.B115200, control_chars])
class Port(object):
"""One serial port, held open for the whole pass."""
def __init__(self, dev):
self.dev = dev
def __init__(self, device):
self.device = device
self.fd = None
self.done = False # no more work to do on this port
self.verdict = None # what to print at the end
self.ok = True # counts towards the exit status
self.banners = 0
self.gos = 0 # how many 'A' have gone out
self.a_sent = 0 # how many 'A' have gone out
self.acks = []
self.bytes_seen = 0
self.last_go = None # when 'A' was last sent
@@ -146,21 +147,21 @@ class Port(object):
def open(self):
try:
self.fd = os.open(self.dev, os.O_RDWR | os.O_NOCTTY)
except OSError as e:
self.fail("%s" % e.strerror)
self.fd = os.open(self.device, os.O_RDWR | os.O_NOCTTY)
except OSError as err:
self.fail("%s" % err.strerror)
return False
try:
configure(self.fd)
except termios.error as e:
self.fail("cannot configure: %s" % e)
except termios.error as err:
self.fail("cannot configure: %s" % err)
return False
return True
def fail(self, msg):
def fail(self, reason):
self.done = True
self.ok = False
self.verdict = msg
self.verdict = reason
self.close()
def close(self):
@@ -171,8 +172,8 @@ class Port(object):
def read(self, size=64):
try:
return os.read(self.fd, size)
except OSError as e:
self.fail("read failed: %s" % e.strerror)
except OSError as err:
self.fail("read failed: %s" % err.strerror)
return None
def poll(self, now):
@@ -182,8 +183,8 @@ class Port(object):
return
if data:
self.bytes_seen += len(data)
buf = self.tail + data
self.tail = buf[-(len(BANNER) - 1):] if len(BANNER) > 1 else b""
seen = self.tail + data
self.tail = seen[-(len(BANNER) - 1):] if len(BANNER) > 1 else b""
# Deliberately stricter than stock. firmwareExe only requires a
# literal "Ready" on the heat board; on ttyS5 and ttyS7 its ready
@@ -191,7 +192,7 @@ class Port(object):
# on the strength of that. We will not: a board that is already
# running is speaking the Klipper protocol, and 'A' has no business
# on that wire. The banner is the only thing that earns a write.
if BANNER in buf:
if BANNER in seen:
self.banners += 1
self.handshake(now)
return
@@ -203,11 +204,11 @@ class Port(object):
self.verdict = ("started its application (ack 0x%02x, not "
"0x%02x -- but the banner stopped after %d "
"'A', so it did leave the bootloader)"
% (ack, GOOD_ACK, self.gos))
% (ack, GOOD_ACK, self.a_sent))
else:
self.verdict = ("no ack to %d 'A', but the banner stopped "
"-- assuming it left the bootloader"
% self.gos)
% self.a_sent)
self.done = True
def handshake(self, now):
@@ -250,7 +251,7 @@ class Port(object):
self.last_go = now
for _ in range(ACK_TRIES):
os.write(self.fd, GO)
self.gos += 1
self.a_sent += 1
# Each read is one VTIME tick, so this paces itself at ~10 'A'/s
# and the whole loop is bounded by 50 * 0.1s = 5s.
ack = self.read(1)
@@ -261,7 +262,7 @@ class Port(object):
if ack[0] == GOOD_ACK:
self.verdict = ("started its application (ack 0x%02x "
"after %d 'A', %d banner(s))"
% (ack[0], self.gos, self.banners))
% (ack[0], self.a_sent, self.banners))
self.done = True
return
# Drop anything left over from the banner so the next poll does not
@@ -277,7 +278,7 @@ class Port(object):
ack = self.acks[-1] if self.acks else None
self.verdict = ("still bannering after %d 'A' (%d banners, last "
"ack %s) -- did NOT leave the bootloader"
% (self.gos, self.banners,
% (self.a_sent, self.banners,
"0x%02x" % ack if ack is not None else "none"))
self.ok = False
elif self.bytes_seen:
@@ -291,47 +292,48 @@ class Port(object):
self.done = True
def bringup(devs, deadline_s=DEADLINE_S, on_progress=None):
def bringup(devices, deadline_s=DEADLINE_S, on_progress=None):
"""Hand every board over, in one pass.
on_progress(devs_still_working) is called whenever that set shrinks, so a
on_progress(devices_still_working) is called whenever that set
shrinks, so a
caller can say which board is being waited for. It is called from inside
the poll loop and must be cheap; anything it raises is the caller's
problem to have avoided, because a bring-up must not fail over a progress
note.
"""
ports = [Port(d) for d in devs]
live = [p for p in ports if p.open()]
ports = [Port(device) for device in devices]
live = [port for port in ports if port.open()]
try:
end = time.monotonic() + deadline_s
deadline = time.monotonic() + deadline_s
waiting = None
while True:
pending = [p for p in live if not p.done]
if not pending or time.monotonic() >= end:
pending = [port for port in live if not port.done]
if not pending or time.monotonic() >= deadline:
break
# Only when the set actually shrinks: this loop spins hard, and
# calling back on every pass would be pure noise.
names = tuple(p.dev for p in pending)
names = tuple(port.device for port in pending)
if names != waiting:
waiting = names
if on_progress is not None:
on_progress(list(names))
for p in pending:
p.poll(time.monotonic())
for p in live:
p.finish(deadline_s)
for port in pending:
port.poll(time.monotonic())
for port in live:
port.finish(deadline_s)
finally:
for p in live:
p.close()
for port in live:
port.close()
if on_progress is not None:
on_progress([])
ok = True
for p in ports:
print("mcu-bringup: %s %s" % (p.dev, p.verdict))
if not p.ok:
ok = False
return ok
all_ok = True
for port in ports:
print("mcu-bringup: %s %s" % (port.device, port.verdict))
if not port.ok:
all_ok = False
return all_ok
def main(argv):
+39 -37
View File
@@ -155,9 +155,9 @@ class Screen:
self.stride = 0
else:
try:
xres, yres = _read(
width_text, height_text = _read(
os.path.join(sysfs, 'virtual_size')).split(',')
self.buf_w, self.buf_h = int(xres), int(yres)
self.buf_w, self.buf_h = int(width_text), int(height_text)
self.bpp = int(_read(os.path.join(sysfs, 'bits_per_pixel')))
except Exception:
return
@@ -193,8 +193,8 @@ class Screen:
if self.bpp == 32:
# The usual Linux packing: B, G, R, then an ignored byte.
return bytes((b, g, r, 0xFF))
v = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
return bytes((v & 0xFF, v >> 8))
packed = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
return bytes((packed & 0xFF, packed >> 8))
def _blank(self):
row = self._pixel(BACKGROUND) * self.buf_w
@@ -214,22 +214,22 @@ class Screen:
w, h = x1 - x0, y1 - y0
if self.rotate == 90:
# display(x, y) lives at buffer(y, buf_h - 1 - x)
bx, by, bw, bh = y0, self.buf_h - x0 - w, h, w
dest_x, dest_y, dest_w, dest_h = y0, self.buf_h - x0 - w, h, w
elif self.rotate == 270:
bx, by, bw, bh = self.buf_w - y0 - h, x0, h, w
dest_x, dest_y, dest_w, dest_h = self.buf_w - y0 - h, x0, h, w
else:
bx, by, bw, bh = x0, y0, w, h
dest_x, dest_y, dest_w, dest_h = x0, y0, w, h
px = self._pixel(rgb)
span = px * bw
step = self.bpp // 8
for row in range(by, by + bh):
start = row * self.stride + bx * step
buf[start:start + len(span)] = span
pixel = self._pixel(rgb)
row_bytes = pixel * dest_w
bytes_per_pixel = self.bpp // 8
for row in range(dest_y, dest_y + dest_h):
offset = row * self.stride + dest_x * bytes_per_pixel
buf[offset:offset + len(row_bytes)] = row_bytes
def _text(self, buf, x, y, text, scale, rgb):
for ch in text.upper():
glyph = FONT.get(ch)
for char in text.upper():
glyph = FONT.get(char)
if glyph:
for col in range(GLYPH_W):
bits = glyph[col]
@@ -275,12 +275,12 @@ class Screen:
if len(lines) < max_lines and current:
lines.append(current)
# Anything still too wide on its own (one very long word) gets cut.
out = []
clipped = []
for line in lines:
while len(line) > 1 and self._width_of(line, scale) > room:
line = line[:-1]
out.append(line)
return out
clipped.append(line)
return clipped
# -- what callers use --------------------------------------------------
@@ -340,13 +340,13 @@ class Screen:
self._center(buf, line, y, detail_scale, colour)
y += GLYPH_H * detail_scale + 2 * detail_scale
if progress is not None:
bw = int(self.width * 0.56)
bh = max(4, self.height // 100)
bx = (self.width - bw) // 2
by = int(self.height * 0.58)
self._rect(buf, bx, by, bw, bh, BAR_BG)
filled = int(bw * min(1.0, max(0.0, progress)))
self._rect(buf, bx, by, filled, bh, BAR_FG)
bar_width = int(self.width * 0.56)
bar_height = max(4, self.height // 100)
bar_x = (self.width - bar_width) // 2
bar_y = int(self.height * 0.58)
self._rect(buf, bar_x, bar_y, bar_width, bar_height, BAR_BG)
filled = int(bar_width * min(1.0, max(0.0, progress)))
self._rect(buf, bar_x, bar_y, filled, bar_height, BAR_FG)
if note:
self._center(buf, note, int(self.height * 0.78),
note_scale, NOTE)
@@ -379,18 +379,20 @@ def main(argv):
the migration exits at its stamp and draws nothing.
"""
import argparse
ap = argparse.ArgumentParser(description='draw one frame on /dev/fb0')
ap.add_argument('--fb', default='/dev/fb0')
ap.add_argument('--size', default=None, help='WxH@BPP; default: ask sysfs')
ap.add_argument('--rotate', type=int, default=None, choices=[0, 90, 270])
ap.add_argument('--title', default='')
ap.add_argument('--status', default='')
ap.add_argument('--detail', default='')
ap.add_argument('--note', default='')
ap.add_argument('--progress', type=float, default=None)
ap.add_argument('--fault', action='store_true')
ap.add_argument('--clear', action='store_true')
args = ap.parse_args(argv)
parser = argparse.ArgumentParser(description='draw one frame on /dev/fb0')
parser.add_argument('--fb', default='/dev/fb0')
parser.add_argument('--size', default=None,
help='WxH@BPP; default: ask sysfs')
parser.add_argument('--rotate', type=int, default=None,
choices=[0, 90, 270])
parser.add_argument('--title', default='')
parser.add_argument('--status', default='')
parser.add_argument('--detail', default='')
parser.add_argument('--note', default='')
parser.add_argument('--progress', type=float, default=None)
parser.add_argument('--fault', action='store_true')
parser.add_argument('--clear', action='store_true')
args = parser.parse_args(argv)
try:
screen = Screen(args.fb, geometry=parse_geometry(args.size),
rotate=args.rotate)
+8 -8
View File
@@ -36,8 +36,8 @@ echo "=== mod firmwareExe `date 2>/dev/null` ==="
# it -- /usr/prog/start.img exists, but the only two lines that would display
# it (both in app_startup.sh) are commented out, so nothing has shown it for
# a long time.
FF_PY=/usr/prog/Python-3.8.2/bin/python3
[ -x "$FF_PY" ] || FF_PY=python3
FF_PYTHON=/usr/prog/Python-3.8.2/bin/python3
[ -x "$FF_PYTHON" ] || FF_PYTHON=python3
LD_LIBRARY_PATH=/usr/prog/Python-3.8.2/lib:$LD_LIBRARY_PATH
LD_LIBRARY_PATH=/usr/prog/openssl-1.0.2d/lib:$LD_LIBRARY_PATH
LD_LIBRARY_PATH=/usr/prog/libffi-3.4.4/lib:$LD_LIBRARY_PATH
@@ -45,7 +45,7 @@ export LD_LIBRARY_PATH
splash() {
[ "${MOD_SPLASH:-1}" = 1 ] || return 0
[ -f $MODDIR/bin/ffscreen.py ] || return 0
"$FF_PY" $MODDIR/bin/ffscreen.py "$@" 2>/dev/null || true
"$FF_PYTHON" $MODDIR/bin/ffscreen.py "$@" 2>/dev/null || true
}
splash --title "REFORGE" --status "STARTING THE PRINTER" --progress 0.02
@@ -53,10 +53,10 @@ splash --title "REFORGE" --status "STARTING THE PRINTER" --progress 0.02
# init.d-style scripts, run in filename order. One failing service must never
# stop the rest: Klipper has to come up even if the web stack is broken.
if [ -d $MODDIR/init.d ]; then
for s in $MODDIR/init.d/S*; do
[ -x "$s" ] || continue
echo "--- `basename $s` ---"
"$s" start || echo "`basename $s`: returned $?"
for service in $MODDIR/init.d/S*; do
[ -x "$service" ] || continue
echo "--- `basename $service` ---"
"$service" start || echo "`basename $service`: returned $?"
done
fi
@@ -78,7 +78,7 @@ if [ "${MOD_STARTUP:-1}" = 1 ] && [ -f $MODDIR/bin/ff-startup.py ]; then
STARTUP_ARGS="--timeout ${MOD_STARTUP_TIMEOUT:-240}"
STARTUP_ARGS="$STARTUP_ARGS --klipper-tries ${MOD_KLIPPER_TRIES:-6}"
[ "${MOD_IMPORT:-1}" = 1 ] || STARTUP_ARGS="$STARTUP_ARGS --no-import"
"$FF_PY" $MODDIR/bin/ff-startup.py $STARTUP_ARGS \
"$FF_PYTHON" $MODDIR/bin/ff-startup.py $STARTUP_ARGS \
|| echo "ff-startup: returned $? -- the next boot will try again"
fi
+6 -6
View File
@@ -47,11 +47,11 @@ supplicant_running() {
connect() {
# app_startup.sh insmods 8821cu just before us, but the interface takes a
# moment to register.
i=0
while [ $i -lt 15 ]; do
waited=0
while [ $waited -lt 15 ]; do
[ -d /sys/class/net/$IFACE ] && break
sleep 1
i=$((i + 1))
waited=$((waited + 1))
done
if [ ! -d /sys/class/net/$IFACE ]; then
echo "wifi: $IFACE never appeared -- is 8821cu.ko loaded?"
@@ -71,11 +71,11 @@ connect() {
fi
# Give association a chance before asking for a lease.
i=0
while [ $i -lt 20 ]; do
waited=0
while [ $waited -lt 20 ]; do
/usr/sbin/wpa_cli -i $IFACE status 2>/dev/null | grep -q '^wpa_state=COMPLETED' && break
sleep 1
i=$((i + 1))
waited=$((waited + 1))
done
/sbin/udhcpc -i $IFACE -b -t 5 >/dev/null 2>&1 &
echo "wifi: udhcpc requesting a lease on $IFACE"
+26 -24
View File
@@ -12,12 +12,12 @@
MODDIR=/usr/data/anvil
[ -f $MODDIR/anvil.conf ] && . $MODDIR/anvil.conf
NGINX=/usr/prog/nginx/sbin/nginx
NGX_PID=$MODDIR/nginx/logs/nginx.pid
MR_PID=/run/moonraker.pid
MR_LOG=/usr/data/logs/moonraker.log
MR_TMP=/usr/data/tmp
MR_DATA=/usr/data
MR_PY=/usr/prog/Python-3.8.2/bin/python3
NGINX_PID=$MODDIR/nginx/logs/nginx.pid
MOONRAKER_PID=/run/moonraker.pid
MOONRAKER_LOG=/usr/data/logs/moonraker.log
MOONRAKER_TMP=/usr/data/tmp
MOONRAKER_DATA=/usr/data
MOONRAKER_PYTHON=/usr/prog/Python-3.8.2/bin/python3
# Which moonraker actually runs. The INSTALLED tree first: run-append.sh
# already decided what belongs there -- the mod's build if it passed the
@@ -25,8 +25,8 @@ MR_PY=/usr/prog/Python-3.8.2/bin/python3
# that decision here would defeat the rollback it exists to provide. The copy
# on the data partition is the fallback for a printer where /usr/prog has no
# moonraker at all.
MR_MAIN=/usr/prog/moonraker/moonraker/moonraker/moonraker.py
[ -f "$MR_MAIN" ] || MR_MAIN=$MODDIR/moonraker/moonraker.py
MOONRAKER_MAIN=/usr/prog/moonraker/moonraker/moonraker/moonraker.py
[ -f "$MOONRAKER_MAIN" ] || MOONRAKER_MAIN=$MODDIR/moonraker/moonraker.py
start() {
[ "${MOD_WEB:-1}" = "1" ] || { echo "web: disabled in anvil.conf"; return 0; }
@@ -37,7 +37,7 @@ start() {
else
echo "nginx: not available"
fi
if [ -x "$MR_PY" ]; then
if [ -x "$MOONRAKER_PYTHON" ]; then
# This interpreter does not start without its own libraries on the
# path: without them python3 dies on "libpython3.8.so.1.0: cannot
# open shared object file", moonraker never listens on 7125, and
@@ -72,11 +72,13 @@ start() {
# * TMPDIR. Moonraker writes temporary files -- uploads among them
# -- and /tmp here is a ramdisk, so a large upload without this
# fills memory instead of the data partition.
rm -rf $MR_TMP && mkdir -p $MR_TMP
export TMPDIR=$MR_TMP
if [ -f "$MR_MAIN" ]; then
start-stop-daemon -S -b -m -p $MR_PID --exec $MR_PY \
-- $MR_MAIN -d $MR_DATA && echo "moonraker: started ($MR_MAIN)"
rm -rf $MOONRAKER_TMP && mkdir -p $MOONRAKER_TMP
export TMPDIR=$MOONRAKER_TMP
if [ -f "$MOONRAKER_MAIN" ]; then
start-stop-daemon -S -b -m -p $MOONRAKER_PID \
--exec $MOONRAKER_PYTHON \
-- $MOONRAKER_MAIN -d $MOONRAKER_DATA \
&& echo "moonraker: started ($MOONRAKER_MAIN)"
moonraker_check &
else
echo "moonraker: no moonraker.py found -- not started"
@@ -89,33 +91,33 @@ start() {
# boot log, with the reason, because "moonraker did not start" is otherwise a
# support conversation rather than a log line.
moonraker_alive() {
[ -f "$MR_PID" ] || return 1
kill -0 `cat "$MR_PID"` 2>/dev/null
[ -f "$MOONRAKER_PID" ] || return 1
kill -0 `cat "$MOONRAKER_PID"` 2>/dev/null
}
moonraker_check() {
n=0
while [ $n -lt 30 ]; do
waited=0
while [ $waited -lt 30 ]; do
sleep 2
n=$((n + 1))
waited=$((waited + 1))
moonraker_alive && continue
echo "moonraker: NOT running after $((n * 2))s -- last words:"
tail -n 15 "$MR_LOG" 2>/dev/null | sed 's/^/ /'
echo "moonraker: NOT running after $((waited * 2))s -- last words:"
tail -n 15 "$MOONRAKER_LOG" 2>/dev/null | sed 's/^/ /'
return 1
done
echo "moonraker: up"
return 0
}
stop() {
[ -f "$NGX_PID" ] && kill `cat $NGX_PID` 2>/dev/null
[ -f "$MR_PID" ] && start-stop-daemon -K -p $MR_PID 2>/dev/null
[ -f "$NGINX_PID" ] && kill `cat $NGINX_PID` 2>/dev/null
[ -f "$MOONRAKER_PID" ] && start-stop-daemon -K -p $MOONRAKER_PID 2>/dev/null
echo "web: stopped"
}
case "$1" in
start|'') start ;;
stop) stop ;;
restart) stop; sleep 2; start ;;
status) [ -f "$NGX_PID" ] && echo "nginx: running" || echo "nginx: not running"
status) [ -f "$NGINX_PID" ] && echo "nginx: running" || echo "nginx: not running"
moonraker_alive && echo "moonraker: running" || echo "moonraker: not running" ;;
*) echo "usage: $0 {start|stop|restart|status}"; exit 1 ;;
esac
+3 -3
View File
@@ -41,10 +41,10 @@ streamer_running() {
# The part that waits. Detached from the boot sequence, so nothing reads its
# exit code -- it reports through the boot log only.
supervise() {
i=0
while [ ! -e "$VIDEO" ] && [ $i -lt 30 ]; do
waited=0
while [ ! -e "$VIDEO" ] && [ $waited -lt 30 ]; do
sleep 1
i=$((i + 1))
waited=$((waited + 1))
done
if [ ! -e "$VIDEO" ]; then
echo "camera: $VIDEO never appeared -- no camera plugged in?"
+17 -14
View File
@@ -80,8 +80,8 @@ klippy_verdict() {
}
supervise() {
n=1
while [ $n -le $KLIPPER_TRIES ]; do
attempt=1
while [ $attempt -le $KLIPPER_TRIES ]; do
# NOT `wc -l < "$PRINTER_LOG" 2>/dev/null`: redirections are applied
# left to right, so the input redirect fails and the shell prints
# "can't open ...: no such file" BEFORE 2>/dev/null is in effect. On
@@ -89,39 +89,42 @@ supervise() {
# in the boot log every time, interleaved with whatever else was
# writing -- which is what made one user's log unreadable at exactly
# the point they were trying to diagnose.
mark=`cat "$PRINTER_LOG" 2>/dev/null | wc -l`
[ -n "$mark" ] || mark=0
log_lines=`cat "$PRINTER_LOG" 2>/dev/null | wc -l`
[ -n "$log_lines" ] || log_lines=0
# A failed connect takes ~90s to be reported, so wait out more than
# that before concluding anything.
w=0
waited=0
verdict=""
while [ $w -lt 30 ]; do
while [ $waited -lt 30 ]; do
sleep 5
w=$((w + 1))
verdict=`klippy_verdict "$mark"`
waited=$((waited + 1))
verdict=`klippy_verdict "$log_lines"`
[ -n "$verdict" ] && break
done
case "$verdict" in
ready)
echo "klipper: ready (attempt $n)"
echo "klipper: ready (attempt $attempt)"
return 0
;;
"")
# Neither marker showed up. Something other than an MCU
# connect is going on; restarting would only hide it.
echo "klipper: no verdict after attempt $n -- leaving it alone"
echo "klipper: no verdict after attempt $attempt -- leaving it alone"
return 0
;;
esac
echo "klipper: MCU connect failed (attempt $n/$KLIPPER_TRIES) -- restarting"
echo "klipper: MCU connect failed (attempt $attempt/$KLIPPER_TRIES) -- restarting"
[ -x /usr/prog/klipper/klipperDaemon ] && /usr/prog/klipper/klipperDaemon stop
g=0
while klippy_proc && [ $g -lt 15 ]; do sleep 1; g=$((g + 1)); done
stopping=0
while klippy_proc && [ $stopping -lt 15 ]; do
sleep 1
stopping=$((stopping + 1))
done
# start.sh bails out if the socket is still there, so clear a stale
# one -- but only once klippy is definitely gone.
klippy_proc || rm -f /tmp/uds
/usr/prog/klipper/start.sh
n=$((n + 1))
attempt=$((attempt + 1))
done
echo "klipper: still no MCU after $KLIPPER_TRIES attempts -- giving up"
return 1
+114 -107
View File
@@ -62,65 +62,67 @@ class FFFirmwareConfig:
try:
with open(path, 'r') as fh:
text = fh.read()
except IOError as e:
self.errors.append("%s: %s" % (path, e))
except IOError as err:
self.errors.append("%s: %s" % (path, err))
continue
try:
obj, _ = json.JSONDecoder().raw_decode(text.lstrip())
except ValueError as e:
self.errors.append("%s: not valid JSON (%s)" % (path, e))
decoded, _tail = json.JSONDecoder().raw_decode(text.lstrip())
except ValueError as err:
self.errors.append("%s: not valid JSON (%s)" % (path, err))
continue
if not isinstance(obj, dict):
if not isinstance(decoded, dict):
self.errors.append("%s: expected a JSON object" % path)
continue
self.files[name] = obj
self.files[name] = decoded
def num(self, fname, key):
obj = self.files.get(fname)
if obj is None:
def num(self, file_name, key):
contents = self.files.get(file_name)
if contents is None:
return None
val = obj.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
value = contents.get(key)
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
if not math.isfinite(val):
if not math.isfinite(value):
return None
return float(val)
return float(value)
def tool_offsets(self):
rows = []
for index in range(EXTRUDER_COUNT):
offset = [self.num('extruder', 't%d_offset_%s' % (index, axis)) for axis in 'xyz']
offset = [self.num('extruder', 't%d_offset_%s' % (index, axis))
for axis in 'xyz']
rows.append(None if None in offset else tuple(offset))
return rows
def docks(self):
rows = []
for n in range(EXTRUDER_COUNT):
for index in range(EXTRUDER_COUNT):
# first extruder is just "extruder", next is "extruder1"
sfx = '' if n == 0 else '%d' % n
x = self.num('extruder', 'x_check_pos' + sfx)
y = self.num('extruder', 'y_check_pos' + sfx)
rows.append(None if None in (x, y) else (x, y))
suffix = '' if index == 0 else '%d' % index
dock_x = self.num('extruder', 'x_check_pos' + suffix)
dock_y = self.num('extruder', 'y_check_pos' + suffix)
rows.append(None if None in (dock_x, dock_y) else (dock_x, dock_y))
return rows
def station(self):
v = [self.num('extruder', '%s_station_pos' % a) for a in 'xyz']
return None if None in v else tuple(v)
position = [self.num('extruder', '%s_station_pos' % axis)
for axis in 'xyz']
return None if None in position else tuple(position)
def z_adjust(self):
# zoffset.json is 1-BASED: z_offset_t1 is tool 0.
return [self.num('zoffset', 'z_offset_t%d' % (n + 1))
for n in range(EXTRUDER_COUNT)]
return [self.num('zoffset', 'z_offset_t%d' % (index + 1))
for index in range(EXTRUDER_COUNT)]
def speed(self, key):
# mm/s in the file; the app multiplies by 60 and ignores <= 0.
v = self.num('test', key)
if v is None or v <= 0:
mm_per_second = self.num('test', key)
if mm_per_second is None or mm_per_second <= 0:
return None
return int(round(v * 60.0))
return int(round(mm_per_second * 60.0))
class FFLegacy:
@@ -144,19 +146,19 @@ class FFLegacy:
directory = gcmd.get('DIR', self.default_dir)
apply = gcmd.get_int('APPLY', 1, minval=0, maxval=1)
try:
out = self._import(directory, apply)
except ValueError as e:
raise gcmd.error(str(e))
gcmd.respond_info("\n".join(out))
logging.info("%s: %s", self.name, "\n".join(out))
report = self._import(directory, apply)
except ValueError as err:
raise gcmd.error(str(err))
gcmd.respond_info("\n".join(report))
logging.info("%s: %s", self.name, "\n".join(report))
def _import(self, directory, apply):
fw = FFFirmwareConfig(directory)
out = ["import from %s" % directory]
out += [" ! %s" % e for e in fw.errors]
if 'extruder' not in fw.files:
firmware = FFFirmwareConfig(directory)
report = ["import from %s" % directory]
report += [" ! %s" % problem for problem in firmware.errors]
if 'extruder' not in firmware.files:
raise ValueError("%s: extruder.json could not be read (%s)"
% (self.name, "; ".join(fw.errors)))
% (self.name, "; ".join(firmware.errors)))
configfile = self.printer.lookup_object('configfile')
staged = []
@@ -167,87 +169,92 @@ class FFLegacy:
staged.append(" [%s] %s = %.6f" % (section, option, value))
# -- per-unit data -> autosave -----------------------------------
tools = fw.tool_offsets()
zadj = fw.z_adjust()
docks = fw.docks()
for n in range(EXTRUDER_COUNT):
sec = 'ff_tool %d' % n
if docks[n] is not None:
stage(sec, 'dock_x', docks[n][0])
stage(sec, 'dock_y', docks[n][1])
obj = self.printer.lookup_object(sec, None)
if apply and obj is not None:
obj.dock_x, obj.dock_y = docks[n]
nozzles = firmware.tool_offsets()
z_adjusts = firmware.z_adjust()
docks = firmware.docks()
for index in range(EXTRUDER_COUNT):
section = 'ff_tool %d' % index
if docks[index] is not None:
stage(section, 'dock_x', docks[index][0])
stage(section, 'dock_y', docks[index][1])
tool_object = self.printer.lookup_object(section, None)
if apply and tool_object is not None:
tool_object.dock_x, tool_object.dock_y = docks[index]
else:
out.append(" ! x/y_check_pos%s missing -- T%d dock not imported"
% ('' if n == 0 else n, n))
if tools[n] is not None:
stage(sec, 'nozzle_x', tools[n][0])
stage(sec, 'nozzle_y', tools[n][1])
stage(sec, 'nozzle_z', tools[n][2])
obj = self.printer.lookup_object(sec, None)
if apply and obj is not None:
obj.nozzle = tools[n]
report.append(" ! x/y_check_pos%s missing -- T%d dock not"
" imported"
% ('' if index == 0 else index, index))
if nozzles[index] is not None:
stage(section, 'nozzle_x', nozzles[index][0])
stage(section, 'nozzle_y', nozzles[index][1])
stage(section, 'nozzle_z', nozzles[index][2])
tool_object = self.printer.lookup_object(section, None)
if apply and tool_object is not None:
tool_object.nozzle = nozzles[index]
else:
out.append(" ! t%d_offset_x/y/z missing -- T%d not imported"
% (n, n))
if zadj[n]:
stage(sec, 'z_adjust', zadj[n])
obj = self.printer.lookup_object(sec, None)
if apply and obj is not None:
obj.z_adjust = zadj[n]
st = fw.station()
if st is not None:
stage('ff_tool_offset', 'station_x', st[0])
stage('ff_tool_offset', 'station_y', st[1])
stage('ff_tool_offset', 'station_z', st[2])
obj = self.printer.lookup_object('ff_tool_offset', None)
if apply and obj is not None:
obj.station = st
report.append(" ! t%d_offset_x/y/z missing -- T%d not"
" imported" % (index, index))
if z_adjusts[index]:
stage(section, 'z_adjust', z_adjusts[index])
tool_object = self.printer.lookup_object(section, None)
if apply and tool_object is not None:
tool_object.z_adjust = z_adjusts[index]
station = firmware.station()
if station is not None:
stage('ff_tool_offset', 'station_x', station[0])
stage('ff_tool_offset', 'station_y', station[1])
stage('ff_tool_offset', 'station_z', station[2])
offsets = self.printer.lookup_object('ff_tool_offset', None)
if apply and offsets is not None:
offsets.station = station
else:
out.append(" ! x/y/z_station_pos missing -- station not imported")
obj = self.printer.lookup_object('ff_tool_offset', None)
for opt, attr in (('cylinder_x', 'cfg_cylinder_x'),
('cylinder_y', 'cfg_cylinder_y')):
v = fw.num('test', opt)
if v is not None:
stage('ff_tool_offset', opt, v)
if apply and obj is not None:
setattr(obj, attr, v)
report.append(" ! x/y/z_station_pos missing -- station not"
" imported")
offsets = self.printer.lookup_object('ff_tool_offset', None)
for option, attribute in (('cylinder_x', 'cfg_cylinder_x'),
('cylinder_y', 'cfg_cylinder_y')):
value = firmware.num('test', option)
if value is not None:
stage('ff_tool_offset', option, value)
if apply and offsets is not None:
setattr(offsets, attribute, value)
tc = self.printer.lookup_object('ff_toolchange', None)
if apply and tc is not None and hasattr(tc, 'refresh_offsets'):
tc.refresh_offsets()
toolchange = self.printer.lookup_object('ff_toolchange', None)
if apply and toolchange is not None \
and hasattr(toolchange, 'refresh_offsets'):
toolchange.refresh_offsets()
out.append("%s for SAVE_CONFIG:" % ("staged" if apply else "would stage"))
out += staged or [" (nothing)"]
report.append("%s for SAVE_CONFIG:"
% ("staged" if apply else "would stage"))
report += staged or [" (nothing)"]
# -- plain [ff_toolchange] settings -> snippet, only if different --
wanted = []
for opt, key in (('x_correction', 'grabOffset'),
('temp_offset', 'tempOffset')):
v = fw.num('test', key)
if v is not None:
wanted.append((opt, v, "%.6g"))
fast = fw.speed('grabSpeed')
slow = fw.speed('grabSpeedSlow')
if fast is not None:
wanted.append(('fast_feed', fast, "%d"))
if slow is not None:
wanted.append(('slow_feed', slow, "%d"))
wanted.append(('release_slow_feed', slow, "%d"))
for option, key in (('x_correction', 'grabOffset'),
('temp_offset', 'tempOffset')):
value = firmware.num('test', key)
if value is not None:
wanted.append((option, value, "%.6g"))
fast_feed = firmware.speed('grabSpeed')
slow_feed = firmware.speed('grabSpeedSlow')
if fast_feed is not None:
wanted.append(('fast_feed', fast_feed, "%d"))
if slow_feed is not None:
wanted.append(('slow_feed', slow_feed, "%d"))
wanted.append(('release_slow_feed', slow_feed, "%d"))
snippet = []
for opt, v, fmt in wanted:
cur = getattr(tc, opt, None) if tc is not None else None
if cur is None or abs(float(cur) - float(v)) > 1e-9:
snippet.append(("%s: " + fmt) % (opt, v))
for option, value, fmt in wanted:
running = (getattr(toolchange, option, None)
if toolchange is not None else None)
if running is None or abs(float(running) - float(value)) > 1e-9:
snippet.append(("%s: " + fmt) % (option, value))
if snippet:
out.append("[ff_toolchange] differs from the factory JSON --"
" set by hand in ff-toolchange.cfg if intended:")
out += [" " + l for l in snippet]
report.append("[ff_toolchange] differs from the factory JSON --"
" set by hand in ff-toolchange.cfg if intended:")
report += [" " + line for line in snippet]
if apply and staged:
out.append("Then run SAVE_CONFIG to persist the staged values.")
return out
report.append("Then run SAVE_CONFIG to persist the staged values.")
return report
def load_config(config):
+35 -34
View File
@@ -88,42 +88,43 @@ def _parse_metadata(path):
not present. Never raises -- a file we cannot read just yields {}, and
the macro then runs with no derived parameters."""
try:
with open(path, 'rb') as f:
head = f.read(HEAD_BYTES).decode('utf-8', 'replace')
with open(path, 'rb') as fh:
head = fh.read(HEAD_BYTES).decode('utf-8', 'replace')
except Exception:
logging.exception("ff_print: cannot read '%s'", path)
return {}
meta = {}
metadata = {}
# Bed and nozzle: the file's own first heat command, the way the app's
# parser did it (it scanned M104/M109/M140/M190/M141/M191 and reported
# nozzleTemp / bedTemp / chamberTemp).
bed = re.search(r'^M1[49]0 S([0-9]+(?:\.[0-9]+)?)', head, re.M)
if bed is not None and float(bed.group(1)) > 0:
meta['bed'] = int(float(bed.group(1)))
hot = re.search(r'^M10[49] S([0-9]+(?:\.[0-9]+)?)', head, re.M)
if hot is not None and float(hot.group(1)) > 0:
meta['nozzle'] = int(float(hot.group(1)))
bed_match = re.search(r'^M1[49]0 S([0-9]+(?:\.[0-9]+)?)', head, re.M)
if bed_match is not None and float(bed_match.group(1)) > 0:
metadata['bed'] = int(float(bed_match.group(1)))
nozzle_match = re.search(r'^M10[49] S([0-9]+(?:\.[0-9]+)?)', head, re.M)
if nozzle_match is not None and float(nozzle_match.group(1)) > 0:
metadata['nozzle'] = int(float(nozzle_match.group(1)))
# The file's initial extruder (the app's "fisrNozzleIndex", sic).
first = re.search(r'^T([0-%d])\b' % (EXTRUDER_COUNT - 1), head, re.M)
if first is not None:
meta['tool'] = int(first.group(1))
tool_match = re.search(r'^T([0-%d])\b' % (EXTRUDER_COUNT - 1),
head, re.M)
if tool_match is not None:
metadata['tool'] = int(tool_match.group(1))
# First-layer height, from the per-layer marker the slicer emits in the
# body -- one of the three keys the app looked for, and the only one that
# appears near the start of the file. This feeds the print Z offset's
# thin-layer term, which is a FIRST-layer correction, so the first layer's
# height is the value that belongs there.
layer = re.search(r'^;HEIGHT:([0-9.]+)', head, re.M)
if layer is not None:
layer_match = re.search(r'^;HEIGHT:([0-9.]+)', head, re.M)
if layer_match is not None:
try:
meta['layer'] = float(layer.group(1))
metadata['layer'] = float(layer_match.group(1))
except ValueError:
pass
return meta
return metadata
class FFPrint:
@@ -137,8 +138,8 @@ class FFPrint:
in config.getlist('hook_commands',
['SDCARD_PRINT_FILE', 'M23'])
if c.strip()]
self.prev_handlers = {}
self.meta = {}
self.previous_handlers = {}
self.metadata = {}
self.filename = None
self.origin = None
# Latch: only a print WE announced may fire the end macro.
@@ -154,14 +155,14 @@ class FFPrint:
# Same rename dance gcode_macro uses for rename_existing: take the
# command over and keep the previous handler to chain to.
for cmd in self.hook_commands:
prev = self.gcode.register_command(cmd, None)
if prev is None:
previous = self.gcode.register_command(cmd, None)
if previous is None:
raise self.printer.config_error(
"ff_print: command '%s' is not registered -- is there a"
" [virtual_sdcard] section in the config? (Section ORDER"
" does not matter: this runs at klippy:connect, after"
" every section is loaded.)" % (cmd,))
self.prev_handlers[cmd] = prev
self.previous_handlers[cmd] = previous
self.gcode.register_command(
cmd, self._make_handler(cmd),
desc="%s (ff_print: runs %s first)"
@@ -178,10 +179,10 @@ class FFPrint:
'filename': self.filename,
'origin': self.origin,
'active': self.active,
'tool': self.meta.get('tool'),
'nozzle': self.meta.get('nozzle'),
'bed': self.meta.get('bed'),
'layer': self.meta.get('layer'),
'tool': self.metadata.get('tool'),
'nozzle': self.metadata.get('nozzle'),
'bed': self.metadata.get('bed'),
'layer': self.metadata.get('layer'),
}
def _resolve(self, gcmd, cmd):
@@ -196,18 +197,18 @@ class FFPrint:
name = name[1:]
if not name:
return None
vsd = self.printer.lookup_object('virtual_sdcard', None)
if vsd is None:
virtual_sdcard = self.printer.lookup_object('virtual_sdcard', None)
if virtual_sdcard is None:
return None
return os.path.join(vsd.sdcard_dirname, name)
return os.path.join(virtual_sdcard.sdcard_dirname, name)
def _cmd_start(self, cmd, gcmd):
path = self._resolve(gcmd, cmd)
self.filename = path
self.origin = cmd
self.meta = _parse_metadata(path) if path else {}
if self.meta:
logging.info("ff_print: %s -> %s", path, self.meta)
self.metadata = _parse_metadata(path) if path else {}
if self.metadata:
logging.info("ff_print: %s -> %s", path, self.metadata)
else:
logging.info("ff_print: %s -> no slicer metadata found", path)
@@ -215,8 +216,8 @@ class FFPrint:
for key, name, fmt in (('bed', 'BED', '%d'), ('tool', 'TOOL', '%d'),
('nozzle', 'NOZZLE', '%d'),
('layer', 'LAYER', '%s')):
if self.meta.get(key) is not None:
params.append('%s=%s' % (name, fmt % (self.meta[key],)))
if self.metadata.get(key) is not None:
params.append('%s=%s' % (name, fmt % (self.metadata[key],)))
# Let the macro raise: a refusal here must stop the print BEFORE the
# base command loads and resumes the file.
@@ -224,7 +225,7 @@ class FFPrint:
'%s %s' % (self.before_macro, ' '.join(params)))
# Arm the end latch only once prepare succeeded.
self.active = True
self.prev_handlers[cmd](gcmd)
self.previous_handlers[cmd](gcmd)
def _handle_ready(self, print_time):
"""idle_timeout:ready fires whenever the queue drains -- including a
+8 -4
View File
@@ -57,7 +57,8 @@ class FFTool:
self.extruder_name = ('extruder' if self.index == 0
else 'extruder%d' % self.index)
# Measured (TOOL_OFFSET_CALIBRATE) -- one probe run yields all three.
nozzle = [config.getfloat('nozzle_' + a, None) for a in 'xyz']
nozzle = [config.getfloat('nozzle_' + axis, None)
for axis in 'xyz']
if None in nozzle and nozzle != [None, None, None]:
raise config.error("%s: nozzle_x, nozzle_y and nozzle_z must be"
" set together" % self.name)
@@ -101,11 +102,14 @@ class FFTool:
configfile.set(self.name, 'z_adjust', "%.6f" % z)
def get_status(self, eventtime):
nx, ny, nz = self.nozzle if self.nozzle else (None, None, None)
nozzle_x, nozzle_y, nozzle_z = (self.nozzle if self.nozzle
else (None, None, None))
return {'index': self.index, 'dock_x': self.dock_x,
'dock_y': self.dock_y, 'extruder': self.extruder_name,
'z_adjust': self.z_adjust, 'calibrated': self.nozzle is not None,
'nozzle_x': nx, 'nozzle_y': ny, 'nozzle_z': nz}
'z_adjust': self.z_adjust,
'calibrated': self.nozzle is not None,
'nozzle_x': nozzle_x, 'nozzle_y': nozzle_y,
'nozzle_z': nozzle_z}
def load_config_prefix(config):
+208 -173
View File
@@ -74,56 +74,64 @@ CYLINDER_Y_DEFAULT = 214.5
# Port of BaseFunction::fitCircleStable @0x6467f8 (centroid-shifted algebraic
# least squares, cv::solve DECOMP_LU on the 3x3 normal equations):
# A = [2x', 2y', 1], b = x'^2 + y'^2, (A^T A) p = A^T b
# centre = (p0 + xbar, p1 + ybar), r = sqrt(p2 + p0^2 + p1^2)
# centre = (p0 + centroid_x, p1 + centroid_y), r = sqrt(p2 + p0^2 + p1^2)
# The shipped variant calls fitCircleByLeastSquares @0x645b10, the same
# algebraic fit without the centroid shift; for the 4 symmetric points the
# sequence produces both reduce to cx = (px1+px3)/2, cy = (py2+py4)/2. We
# sequence produces both reduce to centre_x = (x_plus+x_minus)/2,
# centre_y = (y_plus+y_minus)/2. We
# keep the shifted form for numerical hygiene and accept >= 3 points.
# Pure Python on purpose: numpy is not guaranteed on the X2000 rootfs.
# ---------------------------------------------------------------------------
def fit_circle(points):
"""Return (cx, cy, r, residuals); raise ValueError if unfittable."""
n = len(points)
if n < 3:
raise ValueError("circle fit needs at least 3 points, got %d" % n)
xbar = sum(p[0] for p in points) / n
ybar = sum(p[1] for p in points) / n
m = [[0.0] * 3 for _ in range(3)]
v = [0.0] * 3
"""Return (center_x, center_y, radius, residuals); raise ValueError if
unfittable."""
point_count = len(points)
if point_count < 3:
raise ValueError("circle fit needs at least 3 points, got %d"
% point_count)
centroid_x = sum(point[0] for point in points) / point_count
centroid_y = sum(point[1] for point in points) / point_count
normal_matrix = [[0.0] * 3 for _ in range(3)]
normal_rhs = [0.0] * 3
for x, y in points:
xs, ys = x - xbar, y - ybar
row = (2.0 * xs, 2.0 * ys, 1.0)
b = xs * xs + ys * ys
shifted_x, shifted_y = x - centroid_x, y - centroid_y
design_row = (2.0 * shifted_x, 2.0 * shifted_y, 1.0)
squared_radius_term = shifted_x * shifted_x + shifted_y * shifted_y
for i in range(3):
v[i] += row[i] * b
normal_rhs[i] += design_row[i] * squared_radius_term
for j in range(3):
m[i][j] += row[i] * row[j]
p = _solve3(m, v)
if p is None:
normal_matrix[i][j] += design_row[i] * design_row[j]
solution = _solve3(normal_matrix, normal_rhs)
if solution is None:
raise ValueError("circle fit is singular (points collinear?)")
cx, cy = p[0] + xbar, p[1] + ybar
rr = p[2] + p[0] * p[0] + p[1] * p[1]
if rr <= 0.0:
raise ValueError("circle fit gave radius^2 = %.6f" % rr)
r = math.sqrt(rr)
resid = [math.hypot(x - cx, y - cy) - r for x, y in points]
return cx, cy, r, resid
center_x = solution[0] + centroid_x
center_y = solution[1] + centroid_y
radius_squared = (solution[2] + solution[0] * solution[0]
+ solution[1] * solution[1])
if radius_squared <= 0.0:
raise ValueError("circle fit gave radius^2 = %.6f" % radius_squared)
radius = math.sqrt(radius_squared)
residuals = [math.hypot(x - center_x, y - center_y) - radius
for x, y in points]
return center_x, center_y, radius, residuals
def _solve3(m, v):
a = [m[i][:] + [v[i]] for i in range(3)]
for c in range(3):
piv = max(range(c, 3), key=lambda r: abs(a[r][c]))
if abs(a[piv][c]) < 1e-12:
def _solve3(matrix, rhs):
"""Gauss-Jordan with partial pivoting on the augmented 3x4 system."""
augmented = [matrix[i][:] + [rhs[i]] for i in range(3)]
for col in range(3):
pivot_row = max(range(col, 3), key=lambda r: abs(augmented[r][col]))
if abs(augmented[pivot_row][col]) < 1e-12:
return None
a[c], a[piv] = a[piv], a[c]
for r in range(3):
if r != c:
f = a[r][c] / a[c][c]
for k in range(c, 4):
a[r][k] -= f * a[c][k]
return [a[i][3] / a[i][i] for i in range(3)]
augmented[col], augmented[pivot_row] = (augmented[pivot_row],
augmented[col])
for row in range(3):
if row != col:
factor = augmented[row][col] / augmented[col][col]
for term in range(col, 4):
augmented[row][term] -= factor * augmented[col][term]
return [augmented[i][3] / augmented[i][i] for i in range(3)]
class FFToolOffsetError(Exception):
@@ -187,13 +195,14 @@ class FFToolOffset:
# Station position measured with the empty carriage
# (STATION_CALIBRATE), autosaved here as station_x/y/z.
sx = config.getfloat('station_x', None)
sy = config.getfloat('station_y', None)
sz = config.getfloat('station_z', None)
if (sx, sy, sz) != (None, None, None) and None in (sx, sy, sz):
station_x = config.getfloat('station_x', None)
station_y = config.getfloat('station_y', None)
station_z = config.getfloat('station_z', None)
station = (station_x, station_y, station_z)
if station != (None, None, None) and None in station:
raise config.error("%s: station_x, station_y and station_z must"
" be set together" % self.name)
self.station = None if sx is None else (sx, sy, sz)
self.station = None if station_x is None else station
self.estop = {}
self.tools = []
@@ -219,10 +228,10 @@ class FFToolOffset:
def _handle_connect(self):
missing = []
for axis in 'XYZ':
obj = self.printer.lookup_object('e_stop %s' % axis, None)
if obj is None or not hasattr(obj, 'run_probe'):
axis_probe = self.printer.lookup_object('e_stop %s' % axis, None)
if axis_probe is None or not hasattr(axis_probe, 'run_probe'):
missing.append('e_stop %s' % axis)
self.estop[axis] = obj
self.estop[axis] = axis_probe
if missing:
raise self.printer.config_error(
"%s: required sections not found: %s (the station sensor is"
@@ -245,15 +254,15 @@ class FFToolOffset:
return x, y
def _check_homed(self, gcmd):
th = self.printer.lookup_object('toolhead')
homed = th.get_status(self.reactor.monotonic())['homed_axes']
if not all(a in homed for a in 'xyz'):
toolhead = self.printer.lookup_object('toolhead')
homed = toolhead.get_status(self.reactor.monotonic())['homed_axes']
if not all(axis in homed for axis in 'xyz'):
raise gcmd.error("%s: home all axes first (homed: '%s')"
% (self.name, homed))
def _current_accel(self):
th = self.printer.lookup_object('toolhead')
return th.get_status(self.reactor.monotonic())['max_accel']
toolhead = self.printer.lookup_object('toolhead')
return toolhead.get_status(self.reactor.monotonic())['max_accel']
def _estop(self, gcmd, axis, target):
"""One ESTOP AXES=<axis> TARGET=<target>, calling the fork's e_stop
@@ -261,16 +270,16 @@ class FFToolOffset:
the command: run_probe() takes sub_cycle_cnt samples (3 in
printer.base.cfg) each retracting back_v, rejects a spread above
error_v, and raises on no-trigger / triggered-before-move."""
obj = self.estop[axis]
obj.position_offset = target
axis_probe = self.estop[axis]
axis_probe.position_offset = target
try:
pos = obj.run_probe(gcmd)
except self.printer.command_error as e:
triggered_at = axis_probe.run_probe(gcmd)
except self.printer.command_error as err:
raise FFToolOffsetError("ESTOP %s TARGET=%.3f failed: %s"
% (axis, target, e))
if pos is None:
% (axis, target, err))
if triggered_at is None:
raise FFToolOffsetError("ESTOP %s returned no position" % axis)
return float(pos)
return float(triggered_at)
# ---------------- the recovered sequence ----------------
@@ -288,34 +297,38 @@ class FFToolOffset:
self._run('G1 X%.3f Y%.3f F%d' % (x0, y0, return_feed))
self._run('M400')
d = self.probe_travel
px1 = self._estop(gcmd, 'X', x0 + d)
reach = self.probe_travel
x_plus = self._estop(gcmd, 'X', x0 + reach)
back()
py2 = self._estop(gcmd, 'Y', y0 + d)
y_plus = self._estop(gcmd, 'Y', y0 + reach)
back()
px3 = self._estop(gcmd, 'X', x0 - d)
x_minus = self._estop(gcmd, 'X', x0 - reach)
back()
py4 = self._estop(gcmd, 'Y', y0 - d)
y_minus = self._estop(gcmd, 'Y', y0 - reach)
back()
pts = [(px1, y0), (x0, py2), (px3, y0), (x0, py4)]
for i, p in enumerate(pts):
gcmd.respond_info(" Point%d: %.4f, %.4f" % (i + 1, p[0], p[1]))
points = [(x_plus, y0), (x0, y_plus), (x_minus, y0), (x0, y_minus)]
for index, point in enumerate(points):
gcmd.respond_info(" Point%d: %.4f, %.4f"
% (index + 1, point[0], point[1]))
try:
cx, cy, r, resid = fit_circle(pts)
except ValueError as e:
raise FFToolOffsetError("circle fit failed: %s" % e)
worst = max(abs(e) for e in resid)
center_x, center_y, radius, residuals = fit_circle(points)
except ValueError as err:
raise FFToolOffsetError("circle fit failed: %s" % err)
worst_residual = max(abs(residual) for residual in residuals)
gcmd.respond_info(" centre %.4f, %.4f radius %.4f max residual %.4f"
% (cx, cy, r, worst))
if self.max_residual and worst > self.max_residual:
% (center_x, center_y, radius, worst_residual))
if self.max_residual and worst_residual > self.max_residual:
raise FFToolOffsetError(
"fit residual %.4f exceeds max_residual %.4f -- a probe"
" mis-triggered; nothing saved" % (worst, self.max_residual))
if self.min_radius and r < self.min_radius:
raise FFToolOffsetError("fitted radius %.4f below min_radius" % r)
if self.max_radius and r > self.max_radius:
raise FFToolOffsetError("fitted radius %.4f above max_radius" % r)
return cx, cy, r
" mis-triggered; nothing saved"
% (worst_residual, self.max_residual))
if self.min_radius and radius < self.min_radius:
raise FFToolOffsetError("fitted radius %.4f below min_radius"
% radius)
if self.max_radius and radius > self.max_radius:
raise FFToolOffsetError("fitted radius %.4f above max_radius"
% radius)
return center_x, center_y, radius
def _z_target_for(self, nominal, expected):
"""Stop z_margin below a known trigger height instead of going all
@@ -339,42 +352,46 @@ class FFToolOffset:
sideways probe finds no edge. Raises FFToolOffsetError either way,
so nothing is damaged and nothing is saved. Runs inside
_with_accel_guard."""
cyl_x, cyl_y = self._cylinder()
expected = self.station[2] if self.station is not None else None
z_t = self._z_target_for(self.z_target, expected)
cylinder_x, cylinder_y = self._cylinder()
expected_z = self.station[2] if self.station is not None else None
z_target = self._z_target_for(self.z_target, expected_z)
self._run('SET_GCODE_OFFSET X=0 Y=0 Z=0 MOVE=0 MOVE_SPEED=600')
self._run('M400')
self._run('G1 Z%.3f F%d' % (self.z_start, FEED_PASS1))
self._run('G1 X%.3f Y%.3f F%d' % (cyl_x, cyl_y, FEED_POSITION))
self._run('G1 X%.3f Y%.3f F%d'
% (cylinder_x, cylinder_y, FEED_POSITION))
self._run('SET_VELOCITY_LIMIT ACCEL=%.0f' % self.probe_accel)
self._run('M400')
hint = (" -- is the build plate still on? Nothing has moved with"
" a nozzle. Remove the plate, or plate_check: False in"
" [ff_tool_offset] if you are sure.")
try:
zp = self._estop(gcmd, 'Z', z_t)
except FFToolOffsetError as e:
station_z = self._estop(gcmd, 'Z', z_target)
except FFToolOffsetError as err:
raise FFToolOffsetError("plate check: station Z probe failed"
" (%s)%s" % (e, hint))
gcmd.respond_info(" plate check: station Z %.3f" % zp)
if expected is not None and zp > expected + self.plate_z_tolerance:
" (%s)%s" % (err, hint))
gcmd.respond_info(" plate check: station Z %.3f" % station_z)
if expected_z is not None \
and station_z > expected_z + self.plate_z_tolerance:
raise FFToolOffsetError(
"plate check: station Z %.3f is %.2f mm above the"
" calibrated %.3f%s" % (zp, zp - expected, expected, hint))
self._run('G1 X%.3f Y%.3f F%d' % (cyl_x, cyl_y, FEED_PASS1))
self._run('G1 Z%.3f F%d' % (zp + self.z_clear, FEED_PASS1))
" calibrated %.3f%s"
% (station_z, station_z - expected_z, expected_z, hint))
self._run('G1 X%.3f Y%.3f F%d' % (cylinder_x, cylinder_y, FEED_PASS1))
self._run('G1 Z%.3f F%d' % (z_trigger + self.z_clear, FEED_PASS1))
self._run('M400')
try:
px = self._estop(gcmd, 'X', cyl_x + self.probe_travel)
except FFToolOffsetError as e:
edge_x = self._estop(gcmd, 'X', cylinder_x + self.probe_travel)
except FFToolOffsetError as err:
raise FFToolOffsetError(
"plate check: no circle edge within %.0f mm of the start"
" point (%s)%s" % (self.probe_travel, e, hint))
self._run('G1 X%.3f F%d' % (cyl_x, FEED_PASS1))
" point (%s)%s" % (self.probe_travel, err, hint))
self._run('G1 X%.3f F%d' % (cylinder_x, FEED_PASS1))
self._run('G1 Z%.3f F%d' % (self.z_start, FEED_PASS1))
self._run('M400')
gcmd.respond_info(" plate check: circle edge at X %.3f (%+.2f from"
" the start point) -- plate is off" % (px, px - cyl_x))
" the start point) -- plate is off"
% (edge_x, edge_x - cylinder_x))
def _run_plate_check(self, gcmd):
"""Park whatever is mounted, then _plate_check. Shared prologue of
@@ -383,25 +400,29 @@ class FFToolOffset:
minval=0, maxval=1):
return
if self.toolchange is not None:
cur = self.toolchange.get_status(self.reactor.monotonic())
if cur.get('current_tool', -1) != -1:
carriage = self.toolchange.get_status(self.reactor.monotonic())
if carriage.get('current_tool', -1) != -1:
self._run('TOOLCHANGE_PARK')
self._wait_moves()
cur = self.toolchange.get_status(self.reactor.monotonic())
if cur.get('current_tool', -1) != -1 or not cur.get('state_ok'):
carriage = self.toolchange.get_status(self.reactor.monotonic())
if carriage.get('current_tool', -1) != -1 \
or not carriage.get('state_ok'):
raise gcmd.error(
"%s: carriage is not verifiably empty (%s) -- cannot run"
" the plate check" % (self.name, cur.get('state_reason')))
" the plate check"
% (self.name, carriage.get('state_reason')))
self._with_accel_guard(gcmd, lambda: self._plate_check(gcmd))
def _two_pass(self, gcmd, x0, y0, z_target_2, expected_z=None):
"""moveCylinderPos + both passes. Returns (cx2, cy2, zP2).
"""moveCylinderPos + both passes.
Returns (center_x_pass2, center_y_pass2, z_trigger_pass2).
Caller has already put the right thing on the carriage (tool or
nothing) and ensured we are homed. expected_z, when known, bounds
both Z probes to expected_z - z_margin."""
z_t1 = self._z_target_for(self.z_target, expected_z)
z_t2 = self._z_target_for(z_target_2, expected_z)
z_target_pass1 = self._z_target_for(self.z_target, expected_z)
z_target_pass2 = self._z_target_for(z_target_2, expected_z)
# moveCylinderPos: zero the G-code offset (the grab applied this
# tool's SET_GCODE_OFFSET), lift, travel, Z probe.
self._run('SET_GCODE_OFFSET X=0 Y=0 Z=0 MOVE=0 MOVE_SPEED=600')
@@ -411,38 +432,43 @@ class FFToolOffset:
self._run('SET_VELOCITY_LIMIT ACCEL=%.0f' % self.probe_accel)
self._run('M400')
self._run('G1 Z%.3f F%d' % (self.z_start, FEED_PASS1))
gcmd.respond_info(" Z probe to %.3f" % z_t1)
zp = self._estop(gcmd, 'Z', z_t1)
gcmd.respond_info(" Z probe pos: %.4f" % zp)
gcmd.respond_info(" Z probe to %.3f" % z_target_pass1)
z_trigger = self._estop(gcmd, 'Z', z_target_pass1)
gcmd.respond_info(" Z probe pos: %.4f" % z_trigger)
# pass 1
self._run('G1 X%.3f Y%.3f F%d' % (x0, y0, FEED_PASS1))
self._run('G1 Z%.3f F%d' % (zp + self.z_clear, FEED_PASS1))
self._run('G1 Z%.3f F%d' % (z_trigger + self.z_clear, FEED_PASS1))
self._run('M400')
gcmd.respond_info(" pass 1 around %.3f, %.3f at Z %.3f"
% (x0, y0, zp + self.z_clear))
cx, cy, _r = self._four_points(gcmd, x0, y0, FEED_PASS1, False)
% (x0, y0, z_trigger + self.z_clear))
center_x, center_y, _radius = self._four_points(
gcmd, x0, y0, FEED_PASS1, False)
# G-code is formatted to 3 decimals (the app's float_to_string(v, 3)),
# so the centre we actually return to is the rounded one; use that
# for the pass-2 point coordinates too.
cx, cy = round(cx, 3), round(cy, 3)
center_x, center_y = round(center_x, 3), round(center_y, 3)
# pass 2: centred on the fit, Z re-probed there
self._run('G1 Z%.3f F%d' % (zp + self.z_lift, FEED_PASS1))
self._run('G1 Y%.3f F%d' % (cy, FEED_PASS2))
self._run('G1 X%.3f F%d' % (cx, FEED_PASS2))
self._run('G1 Z%.3f F%d' % (z_trigger + self.z_lift, FEED_PASS1))
self._run('G1 Y%.3f F%d' % (center_y, FEED_PASS2))
self._run('G1 X%.3f F%d' % (center_x, FEED_PASS2))
self._run('M400')
zp2 = self._estop(gcmd, 'Z', self._z_target_for(z_t2, zp))
gcmd.respond_info(" double Z probe pos: %.4f" % zp2)
self._run('G1 Z%.3f F%d' % (zp2 + self.z_clear, FEED_PASS1))
z_trigger_pass2 = self._estop(
gcmd, 'Z', self._z_target_for(z_target_pass2, z_trigger))
gcmd.respond_info(" double Z probe pos: %.4f" % z_trigger_pass2)
self._run('G1 Z%.3f F%d'
% (z_trigger_pass2 + self.z_clear, FEED_PASS1))
self._run('M400')
gcmd.respond_info(" pass 2 around %.3f, %.3f at Z %.3f"
% (cx, cy, zp2 + self.z_clear))
cx2, cy2, _r2 = self._four_points(gcmd, cx, cy, FEED_PASS2, True)
% (center_x, center_y,
z_trigger_pass2 + self.z_clear))
center_x_pass2, center_y_pass2, _radius_pass2 = self._four_points(
gcmd, center_x, center_y, FEED_PASS2, True)
self._run('G1 Z%.3f F%d' % (self.z_final, FEED_PASS1))
self._run('M400')
return cx2, cy2, zp2
return center_x_pass2, center_y_pass2, z_trigger_pass2
def _with_accel_guard(self, gcmd, body):
"""Run body() with the app's exit block guaranteed: lift, restore
@@ -452,13 +478,13 @@ class FFToolOffset:
restore = self._current_accel()
try:
return body()
except FFToolOffsetError as e:
except FFToolOffsetError as err:
try:
self._run('G1 Z%.3f F%d' % (self.z_start, FEED_PASS1))
self._run('M400')
except self.printer.command_error:
pass
raise gcmd.error("%s: %s" % (self.name, e))
raise gcmd.error("%s: %s" % (self.name, err))
finally:
try:
self._run('SET_VELOCITY_LIMIT ACCEL=%.0f' % restore)
@@ -493,19 +519,19 @@ class FFToolOffset:
" [PLATE_CHECK=1])")
def cmd_TOOL_OFFSET_CALIBRATE(self, gcmd):
raw = gcmd.get('TOOL')
if raw.upper() == 'ALL':
requested = gcmd.get('TOOL')
if requested.upper() == 'ALL':
tools = list(range(EXTRUDER_COUNT))
else:
try:
t = int(raw)
tool_index = int(requested)
except ValueError:
raise gcmd.error("TOOL must be 0..%d or ALL"
% (EXTRUDER_COUNT - 1))
if not 0 <= t < EXTRUDER_COUNT:
if not 0 <= tool_index < EXTRUDER_COUNT:
raise gcmd.error("TOOL must be 0..%d or ALL"
% (EXTRUDER_COUNT - 1))
tools = [t]
tools = [tool_index]
grab = gcmd.get_int('GRAB', 1, minval=0, maxval=1)
release = gcmd.get_int('RELEASE', 0, minval=0, maxval=1)
save = gcmd.get_int('SAVE', 1, minval=0, maxval=1)
@@ -534,8 +560,8 @@ class FFToolOffset:
" PLATE_REMOVED=1), or use GRAB=1 and let the toolchanger"
" pick it up." % (self.name, tools[0] if tools else '?'))
self._run_plate_check(gcmd)
cyl_x, cyl_y = self._cylinder()
x0, y0 = cyl_x - self.nozzle_x_shift, cyl_y
cylinder_x, cylinder_y = self._cylinder()
x0, y0 = cylinder_x - self.nozzle_x_shift, cylinder_y
results = {}
for tool in tools:
@@ -548,8 +574,9 @@ class FFToolOffset:
self._run('T%d' % tool)
self._wait_moves()
t = self.tools[tool]
expected_z = t.nozzle[2] if t.calibrated() else None
tool_object = self.tools[tool]
expected_z = (tool_object.nozzle[2]
if tool_object.calibrated() else None)
if expected_z is None and self.station is not None:
# nozzle fires ~3.2 mm above the empty-carriage trigger
expected_z = self.station[2] + 0.5 * (self.gap_min
@@ -558,20 +585,20 @@ class FFToolOffset:
def body():
return self._two_pass(gcmd, x0, y0, self.z_target,
expected_z)
cx, cy, zp = self._with_accel_guard(gcmd, body)
center_x, center_y, z_trigger = self._with_accel_guard(gcmd, body)
if self.station is not None and (self.gap_min or self.gap_max):
gap = zp - self.station[2]
gap = z_trigger - self.station[2]
if gap < self.gap_min or gap > self.gap_max:
raise gcmd.error(
"%s: T%d nozzle_z %.3f is %.3f above station_z %.3f,"
" outside gap_min/gap_max [%.2f, %.2f] -- probe"
" mis-trigger suspected, nothing saved"
% (self.name, tool, zp, gap, self.station[2],
% (self.name, tool, z_trigger, gap, self.station[2],
self.gap_min, self.gap_max))
results[tool] = (cx, cy, zp)
self.last['t%d' % tool] = (cx, cy, zp)
results[tool] = (center_x, center_y, z_trigger)
self.last['t%d' % tool] = (center_x, center_y, z_trigger)
gcmd.respond_info("T%d: offset = (%.4f, %.4f, %.4f)"
% (tool, cx, cy, zp))
% (tool, center_x, center_y, z_trigger))
if release:
self._run('TOOLCHANGE_PARK')
self._wait_moves()
@@ -580,8 +607,8 @@ class FFToolOffset:
# part-way through TOOL=ALL really does leave nothing saved -- which is
# what the guard messages above promise.
if save:
for tool, (cx, cy, zp) in results.items():
self.tools[tool].set_nozzle(cx, cy, zp)
for tool, (center_x, center_y, z_trigger) in results.items():
self.tools[tool].set_nozzle(center_x, center_y, z_trigger)
# the app's exit block: heater off for the tool(s), Z15
for tool in tools:
@@ -616,30 +643,32 @@ class FFToolOffset:
# releaseFourExtruder: the carriage must be empty for TS.
self._run('TOOLCHANGE_PARK')
self._wait_moves()
cur = self.toolchange.get_status(self.reactor.monotonic())
if cur.get('current_tool', -1) != -1 or not cur.get('state_ok'):
carriage = self.toolchange.get_status(self.reactor.monotonic())
if carriage.get('current_tool', -1) != -1 \
or not carriage.get('state_ok'):
raise gcmd.error(
"%s: carriage is not verifiably empty (%s) -- the station"
" pass must run with no tool mounted"
% (self.name, cur.get('state_reason')))
% (self.name, carriage.get('state_reason')))
elif park:
raise gcmd.error("%s: [ff_toolchange] not loaded -- park the tool"
" by hand and pass PARK=0." % self.name)
self._run_plate_check(gcmd)
cyl_x, cyl_y = self._cylinder()
cylinder_x, cylinder_y = self._cylinder()
gcmd.respond_info("station calibration, start %.3f, %.3f"
% (cyl_x, cyl_y))
% (cylinder_x, cylinder_y))
expected_z = self.station[2] if self.station is not None else None
def body():
return self._two_pass(gcmd, cyl_x, cyl_y, self.z_target_station_2,
expected_z)
cx, cy, zp = self._with_accel_guard(gcmd, body)
self.last['station'] = (cx, cy, zp)
gcmd.respond_info("station = (%.4f, %.4f, %.4f)" % (cx, cy, zp))
return self._two_pass(gcmd, cylinder_x, cylinder_y,
self.z_target_station_2, expected_z)
center_x, center_y, z_trigger = self._with_accel_guard(gcmd, body)
self.last['station'] = (center_x, center_y, z_trigger)
gcmd.respond_info("station = (%.4f, %.4f, %.4f)"
% (center_x, center_y, z_trigger))
if save:
self.set_station(cx, cy, zp)
self.set_station(center_x, center_y, z_trigger)
self._refresh_toolchange(gcmd)
gcmd.respond_info(
"The SAVE_CONFIG command will update the printer config file"
@@ -652,23 +681,24 @@ class FFToolOffset:
cmd_TOOL_OFFSET_STATUS_help = "Show the configured nozzle/station positions"
def cmd_TOOL_OFFSET_STATUS(self, gcmd):
cyl_x, cyl_y = self._cylinder()
lines = ["station start (cylinder_x/y): %.3f, %.3f" % (cyl_x, cyl_y)]
for t in self.tools:
if t.calibrated():
cylinder_x, cylinder_y = self._cylinder()
lines = ["station start (cylinder_x/y): %.3f, %.3f"
% (cylinder_x, cylinder_y)]
for tool in self.tools:
if tool.calibrated():
lines.append("[ff_tool %d] nozzle %.4f, %.4f, %.4f"
% (t.index, t.nozzle[0], t.nozzle[1],
t.nozzle[2]))
% (tool.index, tool.nozzle[0], tool.nozzle[1],
tool.nozzle[2]))
else:
lines.append("[ff_tool %d] nozzle NOT CALIBRATED" % t.index)
lines.append("[ff_tool %d] nozzle NOT CALIBRATED" % tool.index)
if self.station is not None:
lines.append("[%s] station %.4f, %.4f, %.4f"
% ((self.name,) + self.station))
else:
lines.append("[%s] station NOT CALIBRATED" % self.name)
configfile = self.printer.lookup_object('configfile')
st = configfile.get_status(self.reactor.monotonic())
if st.get('save_config_pending'):
configfile_status = configfile.get_status(self.reactor.monotonic())
if configfile_status.get('save_config_pending'):
lines.append("! unsaved calibration pending -- run SAVE_CONFIG")
gcmd.respond_info("\n".join(lines))
@@ -687,38 +717,43 @@ class FFToolOffset:
base_tool = (self.toolchange.offset_base
if self.toolchange is not None else 0)
lines = []
for tool, (cx, cy, zp) in sorted(results.items()):
for tool, (center_x, center_y, z_trigger) in sorted(results.items()):
lines.append("T%d measured: nozzle centre %.4f, %.4f"
" Z trigger %.4f" % (tool, cx, cy, zp))
base = None
" Z trigger %.4f"
% (tool, center_x, center_y, z_trigger))
base_nozzle = None
if base_tool in results:
base = results[base_tool]
base_nozzle = results[base_tool]
elif self.tools[base_tool].calibrated():
base = self.tools[base_tool].nozzle
base_nozzle = self.tools[base_tool].nozzle
z_station = self.station[2] if self.station is not None else None
if base is not None:
if base_nozzle is not None:
lines.append("offsets a toolchange applies (X/Y vs T%d; Z %s):"
% (base_tool,
"absolute: nozzle_z - station_z + z_adjust"
if z_station is not None
else "vs T%d, + z_adjust" % base_tool))
for tool, (cx, cy, zp) in sorted(results.items()):
za = self.tools[tool].z_adjust
for tool, (center_x, center_y, z_trigger) in sorted(
results.items()):
z_adjust = self.tools[tool].z_adjust
if z_station is not None:
z_applied = za + (zp - z_station)
z_applied = z_adjust + (z_trigger - z_station)
else:
z_applied = za + (zp - base[2])
z_applied = z_adjust + (z_trigger - base_nozzle[2])
lines.append(" T%d: dX %+.4f dY %+.4f Z %+.4f"
% (tool, cx - base[0], cy - base[1], z_applied))
% (tool, center_x - base_nozzle[0],
center_y - base_nozzle[1], z_applied))
gcmd.respond_info("\n".join(lines))
def get_status(self, eventtime):
out = {}
for k, v in self.last.items():
out[k] = {'x': v[0], 'y': v[1], 'z': v[2]}
sx, sy, sz = self.station if self.station else (None, None, None)
return {'last': out, 'station_x': sx, 'station_y': sy,
'station_z': sz}
measured = {}
for what, position in self.last.items():
measured[what] = {'x': position[0], 'y': position[1],
'z': position[2]}
station_x, station_y, station_z = (self.station if self.station
else (None, None, None))
return {'last': measured, 'station_x': station_x,
'station_y': station_y, 'station_z': station_z}
def load_config(config):
+203 -180
View File
@@ -86,15 +86,15 @@ ERR_RELEASE_STATE = 144 # E0144 state error after release verify
# ---------------------------------------------------------------------------
class _ToolchangerView:
def __init__(self, tc):
self.tc = tc
def __init__(self, toolchanger):
self.toolchanger = toolchanger
def get_status(self, eventtime):
st = self.tc.get_status(eventtime)
cur = st['current_tool']
if self.tc.changing:
own = self.toolchanger.get_status(eventtime)
mounted = own['current_tool']
if self.toolchanger.changing:
status = 'changing'
elif not st['state_ok']:
elif not own['state_ok']:
status = 'error'
else:
status = 'ready'
@@ -104,55 +104,57 @@ class _ToolchangerView:
# and grab sensors, so the two are the same value by construction.
# They are reported separately anyway, because a UI that only reads
# detected_* must still see a tool.
name = 'T%d' % cur if cur >= 0 else None
name = 'T%d' % mounted if mounted >= 0 else None
return {'name': 'toolchanger',
'status': status,
'tool_number': cur,
'tool_number': mounted,
'tool_numbers': list(range(EXTRUDER_COUNT)),
'tool_names': ['T%d' % i for i in range(EXTRUDER_COUNT)],
'tool': name,
'detected_tool': name,
'detected_tool_number': cur,
'detected_tool_number': mounted,
'has_detection': True,
'state_reason': st['state_reason'],
'print_offset_ready': st['print_offset_ready']}
'state_reason': own['state_reason'],
'print_offset_ready': own['print_offset_ready']}
class _ToolView:
def __init__(self, tc, index):
self.tc = tc
def __init__(self, toolchanger, index):
self.toolchanger = toolchanger
self.index = index
def get_status(self, eventtime):
tc = self.tc
cur = tc.get_status(eventtime)['current_tool']
t = tc.tools[self.index]
toolchanger = self.toolchanger
mounted = toolchanger.get_status(eventtime)['current_tool']
tool = toolchanger.tools[self.index]
# klipper-toolchanger's detect_state: is THIS tool seen on the
# carriage -- our per-tool grab sensor. 'unavailable' if unreadable.
# 'mounted' is upstream's spelling of present (DETECT_PRESENT).
try:
detect = ('mounted' if tc._sensor(tc.grab_sensors[self.index],
eventtime) else 'absent')
detect = ('mounted'
if toolchanger._sensor(
toolchanger.grab_sensors[self.index], eventtime)
else 'absent')
except (FFToolchangeError, IndexError):
detect = 'unavailable'
return {'tool_number': self.index,
'name': 'T%d' % self.index,
'toolchanger': 'toolchanger',
'active': cur == self.index,
'mounted': cur == self.index,
'active': mounted == self.index,
'mounted': mounted == self.index,
'detect_state': detect,
'extruder': t.extruder_name,
'extruder': tool.extruder_name,
# In Klipper the extruder object IS its heater, and this
# machine has no separate [extruder_stepper] sections, so
# upstream's third name has nothing to point at.
'heater': t.extruder_name,
'heater': tool.extruder_name,
'extruder_stepper': None,
'fan': tc.part_fan,
'fan': toolchanger.part_fan,
# the differences actually applied on a grab
'gcode_x_offset': tc.off_x[self.index],
'gcode_y_offset': tc.off_y[self.index],
'gcode_z_offset': tc.off_z[self.index],
'calibrated': t.calibrated()}
'gcode_x_offset': toolchanger.offset_x[self.index],
'gcode_y_offset': toolchanger.offset_y[self.index],
'gcode_z_offset': toolchanger.offset_z[self.index],
'calibrated': tool.calibrated()}
class FFToolchangeError(Exception):
@@ -189,7 +191,7 @@ class FFToolchange:
# and docs/notes/40-offsets.md.
self.offset_base = config.getint('offset_base', 0,
minval=0, maxval=EXTRUDER_COUNT - 1)
self.off_x = self.off_y = self.off_z = [0.0] * EXTRUDER_COUNT
self.offset_x = self.offset_y = self.offset_z = [0.0] * EXTRUDER_COUNT
self.refresh_offsets()
# True while a T<n>/TOOLCHANGE sequence is running (reported as
# toolchanger.status = 'changing').
@@ -372,28 +374,32 @@ class FFToolchange:
tools = self.tools
z_station = self._station_z()
base_ok = tools[base].calibrated()
bx, by, bz = tools[base].nozzle if base_ok else (0.0, 0.0, 0.0)
xs, ys, zs = [], [], []
for t in tools:
if t.calibrated() and base_ok:
xs.append(t.nozzle[0] - bx)
ys.append(t.nozzle[1] - by)
base_x, base_y, base_z = (tools[base].nozzle if base_ok
else (0.0, 0.0, 0.0))
x_offsets, y_offsets, z_offsets = [], [], []
for tool in tools:
if tool.calibrated() and base_ok:
x_offsets.append(tool.nozzle[0] - base_x)
y_offsets.append(tool.nozzle[1] - base_y)
else:
xs.append(0.0)
ys.append(0.0)
if t.calibrated() and z_station is not None:
zs.append(t.z_adjust + (t.nozzle[2] - z_station))
elif t.calibrated() and base_ok:
zs.append(t.z_adjust + (t.nozzle[2] - bz))
x_offsets.append(0.0)
y_offsets.append(0.0)
if tool.calibrated() and z_station is not None:
z_offsets.append(tool.z_adjust + (tool.nozzle[2] - z_station))
elif tool.calibrated() and base_ok:
z_offsets.append(tool.z_adjust + (tool.nozzle[2] - base_z))
else:
zs.append(t.z_adjust)
return xs, ys, zs
z_offsets.append(tool.z_adjust)
return x_offsets, y_offsets, z_offsets
def refresh_offsets(self, gcmd=None):
"""Re-derive after a calibration changed an [ff_tool] live."""
fx, fy, fz = self._derive_offsets(self.offset_base)
changed = (fx, fy, fz) != (self.off_x, self.off_y, self.off_z)
self.off_x, self.off_y, self.off_z = fx, fy, fz
derived_x, derived_y, derived_z = self._derive_offsets(
self.offset_base)
changed = ((derived_x, derived_y, derived_z)
!= (self.offset_x, self.offset_y, self.offset_z))
self.offset_x, self.offset_y, self.offset_z = (derived_x, derived_y,
derived_z)
if gcmd is not None:
gcmd.respond_info("ff_toolchange: offsets %s"
% ("updated" if changed else "unchanged"))
@@ -414,10 +420,11 @@ class FFToolchange:
Reset the remembered term to what is actually applied (nothing), then
re-apply the mounted tool's offsets so Z=0 is the bed again. With the
term at 0 and the live offset at 0 the recovered babystep is 0, so
this applies exactly off_z[tool] -- the frame a fresh grab would set.
this applies exactly offset_z[tool] -- the frame a fresh grab
would set.
"""
self._z_tool_term = 0.0
tool, _why = self._current_tool_or_none()
tool, _reason = self._current_tool_or_none()
if tool is None or tool < 0:
return False
self._apply_tool_diff_offsets(tool)
@@ -428,14 +435,14 @@ class FFToolchange:
return True
def uncalibrated_tools(self):
return [t.index for t in self.tools if not t.calibrated()]
return [tool.index for tool in self.tools if not tool.calibrated()]
def _station_z(self):
"""station_z from [ff_tool_offset] (STATION_CALIBRATE), or None."""
st = self.printer.lookup_object('ff_tool_offset', None)
if st is None or st.station is None:
offsets = self.printer.lookup_object('ff_tool_offset', None)
if offsets is None or offsets.station is None:
return None
return st.station[2]
return offsets.station[2]
# ---------------- plumbing ----------------
@@ -456,24 +463,25 @@ class FFToolchange:
missing.append("gcode_button %s" % name)
for tool in range(EXTRUDER_COUNT):
ename = self._extruder_name(tool)
if self.printer.lookup_object(ename, None) is None:
missing.append(ename)
extruder_name = self._extruder_name(tool)
if self.printer.lookup_object(extruder_name, None) is None:
missing.append(extruder_name)
# gcode_macro objects keep the section name's casing, but the command
# each registers is name.upper() (gcode_macro.py: alias = name.upper())
# -- so compare case-insensitively against the registered macros.
macros = set()
for oname, _obj in self.printer.lookup_objects(module='gcode_macro'):
parts = oname.split(None, 1)
if len(parts) == 2:
macros.add(parts[1].upper())
for object_name, _obj in self.printer.lookup_objects(
module='gcode_macro'):
section_words = object_name.split(None, 1)
if len(section_words) == 2:
macros.add(section_words[1].upper())
for mname in (self.grab_macro, self.grab2_macro,
self.release_macro, self.stop_macro):
cmd = mname.split()[0]
if cmd.upper() not in macros:
missing.append("gcode_macro %s" % cmd)
for macro_name in (self.grab_macro, self.grab2_macro,
self.release_macro, self.stop_macro):
command = macro_name.split()[0]
if command.upper() not in macros:
missing.append("gcode_macro %s" % command)
self.runout_switch = self._resolve_runout_sensors(
'filament_switch_sensor', self.runout_switch_prefix, missing)
@@ -505,7 +513,7 @@ class FFToolchange:
# Mirror the sensors to whatever is on the carriage after a
# restart (the app re-arms at print start only; arming outside a
# print is harmless -- _FF_RUNOUT ignores it unless printing).
tool, _why = self._current_tool_or_none()
tool, _reason = self._current_tool_or_none()
if tool is None or tool < 0:
self._disarm_runout()
else:
@@ -688,16 +696,16 @@ class FFToolchange:
"""Non-throwing variant, for reporting only."""
try:
return self._derive_current_tool(eventtime)
except FFToolchangeError as e:
return None, str(e)
except FFToolchangeError as err:
return None, str(err)
def _dock(self, tool):
t = self.tools[tool]
if not t.has_dock():
tool_object = self.tools[tool]
if not tool_object.has_dock():
raise FFToolchangeError(
"T%d has no dock position ([ff_tool %d] dock_x/dock_y) --"
" run FF_IMPORT_FIRMWARE_CONFIG and SAVE_CONFIG" % (tool, tool))
return t.dock_x + self.x_correction, t.dock_y
return tool_object.dock_x + self.x_correction, tool_object.dock_y
def _extruder_name(self, tool):
return self.tools[tool].extruder_name
@@ -710,8 +718,8 @@ class FFToolchange:
def _parse_axes(raw, error, what):
"""'xyz' -> 'XYZ', rejecting anything that is not an axis letter."""
axes = ''.join(sorted(set(raw.strip().upper())))
bad = [a for a in axes if a not in 'XYZ']
if bad:
not_axis_letters = [letter for letter in axes if letter not in 'XYZ']
if not_axis_letters:
raise error("%s: expected letters from XYZ, got '%s'"
% (what, raw))
return axes
@@ -722,8 +730,8 @@ class FFToolchange:
def _capture_position(self):
"""The G-code position the change is about to disturb."""
gm = self.printer.lookup_object('gcode_move')
return list(gm.get_status()['gcode_position'])
gcode_move = self.printer.lookup_object('gcode_move')
return list(gcode_move.get_status()['gcode_position'])
def _restore_position(self, axes, pos):
"""Put the toolhead back where the change found it.
@@ -743,15 +751,15 @@ class FFToolchange:
"""
if not axes:
return
xy = ' '.join('%s%.3f' % (a, pos[i])
for i, a in enumerate('XY') if a in axes)
xy_move = ' '.join('%s%.3f' % (letter, pos[i])
for i, letter in enumerate('XY') if letter in axes)
# The sequence has already put the modal state back; borrow it and
# return it rather than leaving G90 and the restore feed behind.
self._run('SAVE_GCODE_STATE NAME=_ff_restore_axis')
try:
self._run('G90')
if xy:
self._run('G1 %s F%d' % (xy, self.restore_feed))
if xy_move:
self._run('G1 %s F%d' % (xy_move, self.restore_feed))
if 'Z' in axes:
self._run('G1 Z%.3f F%d' % (pos[2], self.restore_feed))
finally:
@@ -775,15 +783,15 @@ class FFToolchange:
restores the pre-sequence mode and feedrate."""
# gcode_move always exists -- toolhead.py:297 loads it
# unconditionally with its other standard modules.
st = self.printer.lookup_object('gcode_move').get_status()
absolute, speed = (st['absolute_coordinates'], st['speed'])
modal = self.printer.lookup_object('gcode_move').get_status()
absolute, speed = (modal['absolute_coordinates'], modal['speed'])
accel = self.accel_restore or self._current_max_accel()
restore_accel = self.accel_restore or self._current_max_accel()
try:
yield
finally:
self._run(self.stop_macro)
self._run('SET_VELOCITY_LIMIT ACCEL=%d' % accel)
self._run('SET_VELOCITY_LIMIT ACCEL=%d' % restore_accel)
if not absolute:
self._run('G91')
@@ -807,7 +815,7 @@ class FFToolchange:
def _grab(self, tool):
"""Port of CommMgr::doGrabExtruderLatest @0x7a8190."""
dx, dy = self._dock(tool)
dock_x, dock_y = self._dock(tool)
# Precheck: the target must be detected in its dock (20 x 50 ms).
# No motion at all on failure.
@@ -823,7 +831,7 @@ class FFToolchange:
self._run('SET_GCODE_OFFSET X=0 Y=0 MOVE=1 MOVE_SPEED=100')
self._run('G90')
self._run('G1 X%.3f F%d' % (self.x_safe, self.fast_feed))
self._run('G1 Y%.3f' % dy)
self._run('G1 Y%.3f' % dock_y)
self._run('G1 X%.3f' % self.x_approach)
# Up to 3 attempts; within each, poll up to 1 s for the grab
@@ -835,7 +843,7 @@ class FFToolchange:
# back-off to x_approach below is undone before the next
# round of polling. Without it, attempts 2 and 3 poll from
# the backed-off position and can never mate.
self._run('G1 X%.3f F%d' % (dx, self.slow_feed))
self._run('G1 X%.3f F%d' % (dock_x, self.slow_feed))
self._wait_moves()
# the app sleeps before its first poll
@@ -847,7 +855,7 @@ class FFToolchange:
# The pullback feed is the app's literal F4800
# (@0x7a9074), NOT the calibrated slow feed.
self._run('G1 X%.3f F%d'
% (dx - self.grab_pullback, PULLBACK_FEED))
% (dock_x - self.grab_pullback, PULLBACK_FEED))
self._run(self.grab2_macro)
break
@@ -869,10 +877,10 @@ class FFToolchange:
# Verify: the tool must have LEFT its dock and the grab sensor
# must be engaged. (doGrabExtruderLatest: !inLocation && grab)
ok = self._poll_until(
triggered = self._poll_until(
lambda: (not self._in_location(tool)) and self._grab_sensor(),
VERIFY_TIMEOUT)
if not ok:
if not triggered:
raise FFToolchangeError(
"T%d pickup could not be verified: in_dock=%s"
" grab_sensor=%s (firmware error E%04d)"
@@ -910,7 +918,7 @@ class FFToolchange:
MOTOR_RELEASE it never supervises (it re-issues before checking its
counter); we stop at three rather than energise the motor with no one
watching."""
dx, dy = self._dock(tool)
dock_x, dock_y = self._dock(tool)
# Sensors off first (changeExtruderChannel @0x79750c disarms before
# the head swap): nothing the carriage does at the dock may fire a
@@ -935,14 +943,14 @@ class FFToolchange:
# loop. There is NO X280 stage here -- that is grab-only; the
# release staging point is dockX-10 below.
self._run('G1 X%.3f F%d' % (self.x_safe, self.fast_feed))
self._run('G1 Y%.3f' % dy)
self._run('G1 Y%.3f' % dock_y)
for attempt in range(RELEASE_ATTEMPTS):
# The app emits G1 X<dock-10> with NO F (inheriting the
# modal feed: fast on attempt 1, the slow feed on retries)
# and the final mate at the release slow feed.
self._run('G1 X%.3f' % (dx - RELEASE_STAGE_BACKOFF))
self._run('G1 X%.3f F%d' % (dx, self.release_slow_feed))
self._run('G1 X%.3f' % (dock_x - RELEASE_STAGE_BACKOFF))
self._run('G1 X%.3f F%d' % (dock_x, self.release_slow_feed))
self._wait_moves()
# Unlock only once the dock sensor confirms the tool has
@@ -988,10 +996,10 @@ class FFToolchange:
# Verify: tool in its dock AND nothing held.
# (doReleaseExtruderLatest: inLocation && !grab)
ok = self._poll_until(
triggered = self._poll_until(
lambda: self._in_location(tool) and not self._grab_sensor(),
VERIFY_TIMEOUT)
if not ok:
if not triggered:
raise FFToolchangeError(
"state error after releasing T%d: in_dock=%s"
" grab_sensor=%s (firmware error E%04d)"
@@ -1049,8 +1057,8 @@ class FFToolchange:
# on the right hotend for exactly that reason.
if resume is not None:
self._restore_position(restore_axis, resume)
except FFToolchangeError as e:
raise gcmd.error(str(e))
except FFToolchangeError as err:
raise gcmd.error(str(err))
except self.printer.command_error:
# A raw Klipper error (move out of range, shutdown, macro fault)
# escaped the sequence. The finally-clauses restored accel,
@@ -1073,16 +1081,17 @@ class FFToolchange:
"""Apply this tool's offsets after a successful grab.
Port of CommMgr::setGrabGcodeOffsetMgr(tool, onlyZ=false).
off_x/off_y are small tool-to-tool DIFFERENCES against the base
tool; off_z is this tool's ABSOLUTE gap to the bed plane (~+3.2 mm,
offset_x/offset_y are small tool-to-tool DIFFERENCES against the base
tool; offset_z is this tool's ABSOLUTE gap to the bed plane (~+3.2 mm,
see _derive_offsets). Unlike the app, which sets that gap once per
print (setZOffsetWhenPrint) and carries it across changes, every
grab establishes it here -- so the only thing carried is whatever
sits on top of it: TOOLCHANGE_SET_PRINT_OFFSET's job terms and the
user's live babystep.
new Z = off_z[new] + (old Z - off_z[old])
= [t_new_z - z_station + zoff[new]] + job terms + babystep
new Z = offset_z[new] + (old Z - offset_z[old])
= [t_new_z - z_station + z_adjust[new]] + job terms
+ babystep
The app sends two commands, different move speeds, 3-decimal
formatting (BaseFunction::float_to_string(v, 3)):
@@ -1105,9 +1114,9 @@ class FFToolchange:
to plain tool diffs in the raw frame, which is what the stock app's
calibration flows expect."""
babystep = self._gcode_z_offset() - self._z_tool_term
z_term = self.off_z[tool]
z_term = self.offset_z[tool]
self._run('SET_GCODE_OFFSET X=%.3f Y=%.3f MOVE=1 MOVE_SPEED=100'
% (self.off_x[tool], self.off_y[tool]))
% (self.offset_x[tool], self.offset_y[tool]))
self._run('SET_GCODE_OFFSET Z=%.3f MOVE=1 MOVE_SPEED=40'
% (z_term + babystep))
self._z_tool_term = z_term
@@ -1138,7 +1147,8 @@ class FFToolchange:
against the fixed under-bed sensor. Derivation: docs/notes/40-offsets.md.
The gap term (nozzle_z - station_z + z_adjust) is already the grab
offset (off_z, applied by every T<n>); this command re-asserts it and
offset (offset_z, applied by every T<n>); this command re-asserts
it and
adds the job terms, which the next _apply_tool_diff_offsets then
carries as "babystep" -- the app re-adds m_zOffset on every grab.
END/CANCEL must reset with SET_GCODE_OFFSET Z=0 MOVE=1 (app exit
@@ -1148,7 +1158,7 @@ class FFToolchange:
layer = gcmd.get_float('LAYER', 0.)
tool = gcmd.get_int('TOOL', -1, minval=-1, maxval=EXTRUDER_COUNT - 1)
if tool < 0:
current, _why = self._current_tool_or_none()
current, _reason = self._current_tool_or_none()
tool = current if current is not None and current >= 0 else 0
z_station = self._station_z()
@@ -1161,28 +1171,29 @@ class FFToolchange:
" several mm too low; NOT printing is the safe choice. Run"
" STATION_CALIBRATE / TOOL_OFFSET_CALIBRATE (or"
" FF_IMPORT_FIRMWARE_CONFIG) and SAVE_CONFIG." % tool)
tools = [t.nozzle or (0.0, 0.0, 0.0) for t in self.tools]
zoff = [t.z_adjust for t in self.tools]
z = tools[tool][2] - z_station + (nozzle - 120.0) * temp_coeff
nozzles = [tool_object.nozzle or (0.0, 0.0, 0.0)
for tool_object in self.tools]
z_adjusts = [tool_object.z_adjust for tool_object in self.tools]
z = nozzles[tool][2] - z_station + (nozzle - 120.0) * temp_coeff
if bed >= 100.0:
z += 0.08
int_layer = int(layer * 100.0)
if 0 < int_layer <= 10:
z += -0.06
z += zoff[tool]
z += z_adjusts[tool]
gcmd.respond_info(
"print Z offset for T%d: %.3f (gap %.3f, temp %+.3f,"
" bed %+.2f, layer %+.2f, user %+.3f)"
% (tool, z, tools[tool][2] - z_station,
% (tool, z, nozzles[tool][2] - z_station,
(nozzle - 120.0) * temp_coeff,
0.08 if bed >= 100.0 else 0.0,
-0.06 if 0 < int_layer <= 10 else 0.0, zoff[tool]))
-0.06 if 0 < int_layer <= 10 else 0.0, z_adjusts[tool]))
self._run('SET_GCODE_OFFSET X=%.3f Y=%.3f Z=%.3f'
' MOVE=1 MOVE_SPEED=100'
% (self.off_x[tool], self.off_y[tool], z))
# off_z[tool] is the gap + z_adjust part of z (station_z and the
% (self.offset_x[tool], self.offset_y[tool], z))
# offset_z[tool] is the gap + z_adjust part of z (station_z and the
# calibration were checked above); the remainder is the job terms.
self._z_tool_term = self.off_z[tool]
self._z_tool_term = self.offset_z[tool]
cmd_TOOL_Z_ADJUST_help = (
"Per-tool persistent Z correction: TOOL_Z_ADJUST TOOL=<0..3> "
@@ -1202,15 +1213,15 @@ class FFToolchange:
if (adjust is None) == (value is None):
raise gcmd.error("TOOL_Z_ADJUST: give exactly one of ADJUST= or"
" VALUE=")
t = self.tools[tool]
old = t.z_adjust
new = value if value is not None else old + adjust
t.set_z_adjust(new)
tool_object = self.tools[tool]
previous = tool_object.z_adjust
updated = value if value is not None else previous + adjust
tool_object.set_z_adjust(updated)
self.refresh_offsets()
applied = ""
try:
self._wait_moves()
current, _why = self._current_tool_or_none()
current, _reason = self._current_tool_or_none()
except FFToolchangeError:
current = None
if current == tool:
@@ -1219,29 +1230,29 @@ class FFToolchange:
gcmd.respond_info(
"T%d z_adjust %.3f -> %.3f%s. The SAVE_CONFIG command will update"
" the printer config file and restart the printer."
% (tool, old, new, applied))
% (tool, previous, updated, applied))
# ---------------- klipper-toolchanger command aliases ----------------
def _tool_arg(self, gcmd, required=True):
"""klipper-toolchanger accepts T=<number> or TOOL=<name>."""
t = gcmd.get_int('T', None)
if t is None:
tool = gcmd.get_int('T', None)
if tool is None:
name = gcmd.get('TOOL', None)
if name is not None:
name = name.strip()
if name.upper().startswith('T') and name[1:].isdigit():
t = int(name[1:])
tool = int(name[1:])
else:
raise gcmd.error("TOOL must be T0..T%d, got '%s'"
% (EXTRUDER_COUNT - 1, name))
if t is None:
if tool is None:
if required:
raise gcmd.error("T=<n> or TOOL=T<n> is required")
return None
if not 0 <= t < EXTRUDER_COUNT:
if not 0 <= tool < EXTRUDER_COUNT:
raise gcmd.error("T must be 0..%d" % (EXTRUDER_COUNT - 1))
return t
return tool
cmd_SELECT_TOOL_help = ("Select a tool (T=<n> | TOOL=T<n>); same as"
" T<n>. RESTORE_AXIS=<xyz> returns the toolhead")
@@ -1254,12 +1265,12 @@ class FFToolchange:
" the toolhead")
def cmd_UNSELECT_TOOL(self, gcmd):
t = self._tool_arg(gcmd, required=False)
if t is not None:
cur, _why = self._current_tool_or_none()
if cur != t:
tool = self._tool_arg(gcmd, required=False)
if tool is not None:
mounted, _reason = self._current_tool_or_none()
if mounted != tool:
raise gcmd.error("UNSELECT_TOOL: T%d is not the mounted tool"
" (current %s)" % (t, cur))
" (current %s)" % (tool, mounted))
self.cmd_TOOLCHANGE_PARK(gcmd)
cmd_INITIALIZE_TOOLCHANGER_help = (
@@ -1267,11 +1278,11 @@ class FFToolchange:
def cmd_INITIALIZE_TOOLCHANGER(self, gcmd):
self._wait_moves()
cur, why = self._current_tool_or_none()
if cur is None:
raise gcmd.error("toolchanger state not derivable: %s" % why)
mounted, reason = self._current_tool_or_none()
if mounted is None:
raise gcmd.error("toolchanger state not derivable: %s" % reason)
gcmd.respond_info("toolchanger ready, tool_number=%d (%s)"
% (cur, why))
% (mounted, reason))
cmd_ASSIGN_TOOL_help = "Not supported on this toolchanger"
@@ -1291,10 +1302,11 @@ class FFToolchange:
a UI knows it is heating T2, not that T2 means [extruder2]."""
tool = self._tool_arg(gcmd, required=False)
if tool is None:
tool, why = self._current_tool_or_none()
tool, reason = self._current_tool_or_none()
if tool is None or tool < 0:
raise gcmd.error("SET_TOOL_TEMPERATURE: no tool mounted, so"
" T=<n> or TOOL=T<n> is required (%s)" % why)
" T=<n> or TOOL=T<n> is required (%s)"
% reason)
target = gcmd.get_float('TARGET', 0.)
heater = self._extruder_name(tool)
self._run('SET_HEATER_TEMPERATURE HEATER=%s TARGET=%.1f'
@@ -1317,17 +1329,19 @@ class FFToolchange:
gcmd.get_int('ASYNC', 0)
expect = self._tool_arg(gcmd, required=False)
self._wait_moves()
cur, why = self._current_tool_or_none()
if cur is None:
mounted, reason = self._current_tool_or_none()
if mounted is None:
raise gcmd.error("VERIFY_TOOL_DETECTED: toolchanger state not"
" derivable: %s" % why)
if expect is not None and cur != expect:
" derivable: %s" % reason)
if expect is not None and mounted != expect:
raise gcmd.error("VERIFY_TOOL_DETECTED: expected T%d, sensors say"
" %s (%s)"
% (expect,
'T%d' % cur if cur >= 0 else 'no tool', why))
'T%d' % mounted if mounted >= 0
else 'no tool', reason))
gcmd.respond_info("detected %s (%s)"
% ('T%d' % cur if cur >= 0 else 'no tool', why))
% ('T%d' % mounted if mounted >= 0 else 'no tool',
reason))
cmd_SELECT_TOOL_ERROR_help = "Abort the running script: a tool change failed"
@@ -1344,9 +1358,10 @@ class FFToolchange:
def cmd_FF_RUNOUT_ARM(self, gcmd):
tool = gcmd.get_int('TOOL', -1)
if tool < 0:
tool, why = self._current_tool_or_none()
tool, reason = self._current_tool_or_none()
if tool is None or tool < 0:
raise gcmd.error("FF_RUNOUT_ARM: no tool mounted (%s)" % why)
raise gcmd.error("FF_RUNOUT_ARM: no tool mounted (%s)"
% reason)
elif tool >= EXTRUDER_COUNT:
raise gcmd.error("FF_RUNOUT_ARM: TOOL must be 0..%d"
% (EXTRUDER_COUNT - 1))
@@ -1367,57 +1382,64 @@ class FFToolchange:
def cmd_TOOLCHANGE_STATUS(self, gcmd):
self._wait_moves()
tool, why = self._current_tool_or_none()
tool, reason = self._current_tool_or_none()
if tool is None:
lines = ["current_tool=UNKNOWN", " ! %s" % why]
lines = ["current_tool=UNKNOWN", " ! %s" % reason]
else:
lines = ["current_tool=%d (%s)" % (tool, why)]
lines = ["current_tool=%d (%s)" % (tool, reason)]
lines.append(" (derived from the dock sensors; nothing is stored)")
if self.runout_switch or self.runout_motion:
lines.append(" runout sensors armed: %s"
% (", ".join(self._armed_sensors()) or "none"))
for i, n in enumerate(self.dock_sensors):
for i, sensor in enumerate(self.dock_sensors):
try:
lines.append(" T%d in dock (%s): %s"
% (i, n, self._sensor(n)))
except FFToolchangeError as e:
lines.append(" T%d (%s): %s" % (i, n, e))
for n in self.grab_sensors:
% (i, sensor, self._sensor(sensor)))
except FFToolchangeError as err:
lines.append(" T%d (%s): %s" % (i, sensor, err))
for sensor in self.grab_sensors:
try:
lines.append(" grab (%s): %s" % (n, self._sensor(n)))
except FFToolchangeError as e:
lines.append(" grab (%s): %s" % (n, e))
lines.append(" grab (%s): %s"
% (sensor, self._sensor(sensor)))
except FFToolchangeError as err:
lines.append(" grab (%s): %s" % (sensor, err))
lines.append("geometry ([ff_tool n] / [ff_toolchange]):")
for t in self.tools:
if t.calibrated():
for tool_object in self.tools:
if tool_object.calibrated():
lines.append(" T%d nozzle (%.4f, %.4f, %.4f) z_adjust %+.3f"
% (t.index, t.nozzle[0], t.nozzle[1],
t.nozzle[2], t.z_adjust))
% (tool_object.index, tool_object.nozzle[0],
tool_object.nozzle[1], tool_object.nozzle[2],
tool_object.z_adjust))
else:
lines.append(" T%d nozzle NOT CALIBRATED"
" (zero X/Y, z_adjust only in Z)"
% t.index)
sz = self._station_z()
% tool_object.index)
station_z = self._station_z()
lines.append(" station_z %s"
% ("%.4f" % sz if sz is not None else "NOT CALIBRATED"))
% ("%.4f" % station_z if station_z is not None
else "NOT CALIBRATED"))
def series_line(option, values):
lines.append(" %-14s[%s]"
% (option, ", ".join("%.4f" % v for v in values)))
% (option,
", ".join("%.4f" % value for value in values)))
series_line('dock_x', [t.dock_x if t.has_dock() else float('nan')
for t in self.tools])
series_line('dock_y', [t.dock_y if t.has_dock() else float('nan')
for t in self.tools])
missing = [t.index for t in self.tools if not t.has_dock()]
series_line('dock_x',
[tool_object.dock_x if tool_object.has_dock()
else float('nan') for tool_object in self.tools])
series_line('dock_y',
[tool_object.dock_y if tool_object.has_dock()
else float('nan') for tool_object in self.tools])
missing = [tool_object.index for tool_object in self.tools
if not tool_object.has_dock()]
if missing:
lines.append(" ! no dock position for T%s -- run"
" FF_IMPORT_FIRMWARE_CONFIG and SAVE_CONFIG"
% ", T".join(str(i) for i in missing))
series_line('offset_z', self.off_z)
series_line('offset_x', self.off_x)
series_line('offset_y', self.off_y)
series_line('offset_z', self.offset_z)
series_line('offset_x', self.offset_x)
series_line('offset_y', self.offset_y)
lines.append(" offset_base T%d" % self.offset_base)
lines.append(" x_correction %.4f" % self.x_correction)
lines.append(" fast_feed %d" % self.fast_feed)
@@ -1433,19 +1455,19 @@ class FFToolchange:
resume = self._capture_position() if restore_axis else None
try:
self._wait_moves()
current, why = self._derive_current_tool()
except FFToolchangeError as e:
raise gcmd.error(str(e))
current, reason = self._derive_current_tool()
except FFToolchangeError as err:
raise gcmd.error(str(err))
if current < 0:
gcmd.respond_info("no tool mounted (%s)" % why)
gcmd.respond_info("no tool mounted (%s)" % reason)
return
try:
self._ensure_homed('xy')
self._release(current)
if resume is not None:
self._restore_position(restore_axis, resume)
except FFToolchangeError as e:
raise gcmd.error(str(e))
except FFToolchangeError as err:
raise gcmd.error(str(err))
def print_offset_ready(self, tool=None):
"""Can TOOLCHANGE_SET_PRINT_OFFSET succeed? Needs station_z and
@@ -1457,12 +1479,13 @@ class FFToolchange:
return self.tools[tool].calibrated()
def get_status(self, eventtime):
tool, why = self._current_tool_or_none(eventtime)
tool, reason = self._current_tool_or_none(eventtime)
return {'current_tool': -1 if tool is None else tool,
'state_ok': tool is not None,
'state_reason': why,
'calibrated_tools': [t.index for t in self.tools
if t.calibrated()],
'state_reason': reason,
'calibrated_tools': [tool_object.index
for tool_object in self.tools
if tool_object.calibrated()],
'station_z': self._station_z(),
# True when every tool and the station are calibrated, i.e.
# a print's Z frame can be established for any tool.
+16 -14
View File
@@ -36,17 +36,18 @@ def main() -> int:
print("usage: moonraker-preflight.py <parent-dir-of-moonraker-package>")
return 1
sys.path.insert(0, sys.argv[1])
conf = sys.argv[2] if len(sys.argv) > 2 else "/usr/data/config/moonraker.conf"
conf_path = (sys.argv[2] if len(sys.argv) > 2
else "/usr/data/config/moonraker.conf")
# Importing the server module first is itself a check, and it is where the
# component list comes from.
try:
import moonraker.server as srv
import moonraker.server as server
except Exception as exc:
print("moonraker.server does not import: %r" % (exc,))
return 1
names = list(getattr(srv, "CORE_COMPONENTS", []))
names = list(getattr(server, "CORE_COMPONENTS", []))
# Whatever the user has configured gets loaded too, so it gets checked too.
# A section that is not a component (e.g. [server]) is skipped below.
# Follow [include ...] too. moonraker.conf is mod-owned and ends with
@@ -62,20 +63,21 @@ def main() -> int:
except OSError:
return
for line in lines:
m = re.match(r"\s*\[\s*([A-Za-z0-9_]+)", line)
if not m:
section = re.match(r"\s*\[\s*([A-Za-z0-9_]+)", line)
if not section:
continue
if m.group(1) == "include":
inc = re.match(r"\s*\[\s*include\s+([^\]]+?)\s*\]", line)
if inc and depth < 3:
base = os.path.dirname(os.path.abspath(path))
for sub in sorted(glob.glob(os.path.join(base,
inc.group(1)))):
scan(sub, depth + 1)
if section.group(1) == "include":
include = re.match(r"\s*\[\s*include\s+([^\]]+?)\s*\]",
line)
if include and depth < 3:
including_dir = os.path.dirname(os.path.abspath(path))
for included in sorted(glob.glob(
os.path.join(including_dir, include.group(1)))):
scan(included, depth + 1)
continue
names.append(m.group(1))
names.append(section.group(1))
scan(conf)
scan(conf_path)
failures = []
for name in dict.fromkeys(names): # de-duplicate, keep order
+63 -61
View File
@@ -19,11 +19,11 @@ for c in /usr/data/update/anvil.tar.xz /mnt/anvil.tar.xz $WORK_DIR/anvil.tar.xz;
done
if [ -n "$MODTAR" ]; then
NEED=`ls -l "$MODTAR" | tr -s ' ' | cut -d' ' -f5`
NEED=$((NEED / 1024 * 4)) # xz payload expands ~3-4x
FREE=`df /usr/data | tail -1 | tr -s ' ' | cut -d' ' -f4`
echo "mod payload: $MODTAR (need ~${NEED}KB, free ${FREE}KB)"
if [ "${FREE:-0}" -lt "$NEED" ]; then
NEED_KB=`ls -l "$MODTAR" | tr -s ' ' | cut -d' ' -f5`
NEED_KB=$((NEED_KB / 1024 * 4)) # xz payload expands ~3-4x
FREE_KB=`df /usr/data | tail -1 | tr -s ' ' | cut -d' ' -f4`
echo "mod payload: $MODTAR (need ~${NEED_KB}KB, free ${FREE_KB}KB)"
if [ "${FREE_KB:-0}" -lt "$NEED_KB" ]; then
echo "!! not enough space on /usr/data -- skipping mod payload"
else
# Keep user-editable state; replace everything we own.
@@ -62,12 +62,12 @@ sync
# whose Moonraker did not survive an update has no web UI at all, and the
# screen would be the only way to notice.
if [ -d $MODDIR/moonraker ]; then
MRROOT=/usr/prog/moonraker/moonraker
if [ -d $MRROOT/moonraker ]; then
MOONRAKER_ROOT=/usr/prog/moonraker/moonraker
if [ -d $MOONRAKER_ROOT/moonraker ]; then
# Both trees exist at once during the swap; /usr/prog is the small
# firmware partition, so check there is room before starting.
NEED=`du -sk $MODDIR/moonraker | cut -f1`
FREE=`df /usr/prog | tail -1 | tr -s ' ' | cut -d' ' -f4`
NEED_KB=`du -sk $MODDIR/moonraker | cut -f1`
FREE_KB=`df /usr/prog | tail -1 | tr -s ' ' | cut -d' ' -f4`
# PRE-FLIGHT: ask THIS printer's python whether it can load the tree
# before anything is moved. moonraker-preflight.py explains what it
# imports and why; the short version is that it uses Moonraker's own
@@ -80,41 +80,43 @@ if [ -d $MODDIR/moonraker ]; then
# is re-run by hand from ssh, where none of that is set -- and a check
# that fails for a missing libsodium would condemn a perfectly good
# build. Same list app_startup.sh uses.
MRPY=/usr/prog/Python-3.8.2/bin/python3
MRIMP=0
if [ -x $MRPY ] && [ -f $MODDIR/moonraker-preflight.py ]; then
MOONRAKER_PYTHON=/usr/prog/Python-3.8.2/bin/python3
MOONRAKER_PREFLIGHT_FAILED=0
if [ -x $MOONRAKER_PYTHON ] && [ -f $MODDIR/moonraker-preflight.py ]; then
(
PATH=$PATH:/usr/prog/Python-3.8.2/bin
for d in /usr/prog/Python-3.8.2/lib /usr/prog/openssl-1.0.2d/lib \
for libdir in /usr/prog/Python-3.8.2/lib \
/usr/prog/openssl-1.0.2d/lib \
/usr/prog/curl-7.55.1/lib /usr/prog/ffmpeg-402/lib \
/usr/prog/x264/lib /usr/prog/libffi-3.4.4/lib \
/usr/prog/libsodium/lib /usr/prog/opencv-4.2/lib \
/usr/prog/nim/lib /usr/prog/libzip-1.10.1/lib; do
[ -d "$d" ] && LD_LIBRARY_PATH="$d:$LD_LIBRARY_PATH"
[ -d "$libdir" ] \
&& LD_LIBRARY_PATH="$libdir:$LD_LIBRARY_PATH"
done
export PATH LD_LIBRARY_PATH
$MRPY $MODDIR/moonraker-preflight.py $MODDIR /usr/data/config/moonraker.conf
) > /tmp/mr-import.log 2>&1 || MRIMP=1
$MOONRAKER_PYTHON $MODDIR/moonraker-preflight.py $MODDIR /usr/data/config/moonraker.conf
) > /tmp/mr-import.log 2>&1 || MOONRAKER_PREFLIGHT_FAILED=1
sed 's/^/ /' /tmp/mr-import.log 2>/dev/null | tail -12
fi
if [ "$MRIMP" != 0 ]; then
if [ "$MOONRAKER_PREFLIGHT_FAILED" != 0 ]; then
echo "!! moonraker: the shipped tree does not load on this printer -- keeping the stock one"
echo " (nothing was moved; the web UI is unaffected)"
elif [ "${FREE:-0}" -lt "$NEED" ]; then
echo "!! moonraker: only ${FREE}KB free on /usr/prog, need ${NEED}KB -- keeping the stock tree"
elif [ "${FREE_KB:-0}" -lt "$NEED_KB" ]; then
echo "!! moonraker: only ${FREE_KB}KB free on /usr/prog, need ${NEED_KB}KB -- keeping the stock tree"
else
rm -rf $MRROOT/moonraker.modold
if mv $MRROOT/moonraker $MRROOT/moonraker.modold; then
if cp -a $MODDIR/moonraker $MRROOT/moonraker; then
rm -rf $MOONRAKER_ROOT/moonraker.modold
if mv $MOONRAKER_ROOT/moonraker $MOONRAKER_ROOT/moonraker.modold; then
if cp -a $MODDIR/moonraker $MOONRAKER_ROOT/moonraker; then
sync
# Bytecode from the FlashForge build would otherwise be
# the first thing imported. Same trap as klippy below.
find $MRROOT/moonraker -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
rm -rf $MRROOT/moonraker.modold
find $MOONRAKER_ROOT/moonraker -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null
rm -rf $MOONRAKER_ROOT/moonraker.modold
echo "moonraker: replaced with the mod's build"
else
rm -rf $MRROOT/moonraker
mv $MRROOT/moonraker.modold $MRROOT/moonraker
rm -rf $MOONRAKER_ROOT/moonraker
mv $MOONRAKER_ROOT/moonraker.modold $MOONRAKER_ROOT/moonraker
echo "!! moonraker: copy failed -- rolled back to the stock tree"
fi
else
@@ -122,7 +124,7 @@ if [ -d $MODDIR/moonraker ]; then
fi
fi
else
echo "!! moonraker: no $MRROOT/moonraker on this printer -- nothing replaced"
echo "!! moonraker: no $MOONRAKER_ROOT/moonraker on this printer -- nothing replaced"
fi
sync
fi
@@ -168,31 +170,31 @@ fi
# has touched it and it is ours to replace. See the branch below.
if [ -d $MODDIR/config ]; then
mkdir -p /usr/data/config
for f in $MODDIR/config/*; do
[ -f "$f" ] || continue
b=`basename "$f"`
live="/usr/data/config/$b"
prev="$MODDIR/config-installed/$b"
case "$b" in
for source in $MODDIR/config/*; do
[ -f "$source" ] || continue
name=`basename "$source"`
live="/usr/data/config/$name"
prev="$MODDIR/config-installed/$name"
case "$name" in
moonraker-custom.conf)
# Yours, permanently. Created once so moonraker.conf's [include]
# resolves -- Moonraker treats an include matching no file as a
# fatal error -- and never touched again, not even as .mod-new.
if [ -f "$live" ]; then
echo "config: $b kept (yours; never overwritten)"
echo "config: $name kept (yours; never overwritten)"
else
cp -f "$f" "$live"
echo "config: $b created -- put your Moonraker settings here"
cp -f "$source" "$live"
echo "config: $name created -- put your Moonraker settings here"
fi
continue
;;
ff-*.cfg)
# Ours. Overwrite and say so when it had drifted, so the log
# explains where a local edit went.
if [ -f "$live" ] && [ "`md5sum < "$live"`" != "`md5sum < "$f"`" ]; then
echo "config: $b overwritten (mod-owned; override it from printer.cfg)"
if [ -f "$live" ] && [ "`md5sum < "$live"`" != "`md5sum < "$source"`" ]; then
echo "config: $name overwritten (mod-owned; override it from printer.cfg)"
fi
cp -f "$f" "$live"
cp -f "$source" "$live"
continue
;;
esac
@@ -203,19 +205,19 @@ if [ -d $MODDIR/config ]; then
# of OURS, and the shipped config could never reach the printer at all.
# runConfig/ holds the pristine template the machine was built with;
# matching it byte for byte means nobody has touched the live copy.
stock="/usr/prog/klipper/runConfig/$b"
stock="/usr/prog/klipper/runConfig/$name"
if [ ! -f "$live" ]; then
cp -f "$f" "$live"
cp -f "$source" "$live"
elif [ -f "$prev" ] && [ "`md5sum < "$live"`" = "`md5sum < "$prev"`" ]; then
cp -f "$f" "$live"
echo "config: $b updated (was unmodified)"
cp -f "$source" "$live"
echo "config: $name updated (was unmodified)"
elif [ ! -f "$prev" ] && [ -f "$stock" ] \
&& [ "`md5sum < "$live"`" = "`md5sum < "$stock"`" ]; then
cp -f "$f" "$live"
echo "config: $b installed over FlashForge's untouched copy"
cp -f "$source" "$live"
echo "config: $name installed over FlashForge's untouched copy"
else
cp -f "$f" "$live.mod-new"
echo "config: $b kept -- new version left as $b.mod-new"
cp -f "$source" "$live.mod-new"
echo "config: $name kept -- new version left as $name.mod-new"
fi
done
# Snapshot what we just shipped, for the NEXT update to compare against.
@@ -251,19 +253,19 @@ sync
# unchanged. The stick only ever sees a password once, on the first install.
PW_KEEP=""
if [ "$MOD_PW_AUTO" = "1" ] && [ -f $MODDIR/.prev-root-hash ]; then
PREV=`cat $MODDIR/.prev-root-hash 2>/dev/null`
PREV_HASH=`cat $MODDIR/.prev-root-hash 2>/dev/null`
# Compare against the hash the PACKAGE ships ($WORK_DIR is the stock
# run.sh's own variable, still in scope here), not the live file: the
# stock installer has already copied the shipped shadow over the live one
# by now, and if that copy ever fails the live file still holds the old
# password -- which must read as "set by someone", not as "fresh".
SHIP=""
SHIPPED_HASH=""
[ -n "${WORK_DIR:-}" ] && [ -f "$WORK_DIR/shadow" ] &&
SHIP=`awk 'BEGIN{FS=":"} $1=="root"{print $2}' "$WORK_DIR/shadow" 2>/dev/null`
[ -n "$SHIP" ] ||
SHIP=`awk 'BEGIN{FS=":"} $1=="root"{print $2}' /usr/prog/etc/shadow 2>/dev/null`
case "$PREV" in
'$'*) [ "$PREV" != "$SHIP" ] && PW_KEEP="$PREV" ;;
SHIPPED_HASH=`awk 'BEGIN{FS=":"} $1=="root"{print $2}' "$WORK_DIR/shadow" 2>/dev/null`
[ -n "$SHIPPED_HASH" ] ||
SHIPPED_HASH=`awk 'BEGIN{FS=":"} $1=="root"{print $2}' /usr/prog/etc/shadow 2>/dev/null`
case "$PREV_HASH" in
'$'*) [ "$PREV_HASH" != "$SHIPPED_HASH" ] && PW_KEEP="$PREV_HASH" ;;
esac
fi
rm -f $MODDIR/.prev-root-hash
@@ -278,16 +280,16 @@ if [ -n "$PW_KEEP" ]; then
echo "!! root password is the stock one -- no ssh login"
fi
elif [ "$MOD_PW_AUTO" = "1" ]; then
PW=`tr -dc A-Za-z0-9 < /dev/urandom 2>/dev/null | head -c 14`
H=""
[ -n "$PW" ] && H=`mkpasswd -m sha512 "$PW" 2>/dev/null`
case "$H" in
NEW_PASSWORD=`tr -dc A-Za-z0-9 < /dev/urandom 2>/dev/null | head -c 14`
NEW_HASH=""
[ -n "$NEW_PASSWORD" ] && NEW_HASH=`mkpasswd -m sha512 "$NEW_PASSWORD" 2>/dev/null`
case "$NEW_HASH" in
'$6$'*)
PWFILE=/mnt/anvil-password.txt
{ echo "anvil -- the root password for this printer"
echo
echo " ssh root@<printer-ip>"
echo " password: $PW"
echo " password: $NEW_PASSWORD"
echo
echo "Save it somewhere safe and delete this file."
echo "To change it, run passwd on the printer."
@@ -295,10 +297,10 @@ elif [ "$MOD_PW_AUTO" = "1" ]; then
sync
# Read it back off the stick: proves the write survived, not just that
# the shell accepted the redirect.
if grep -q "password: $PW" $PWFILE 2>/dev/null; then
if grep -q "password: $NEW_PASSWORD" $PWFILE 2>/dev/null; then
# /etc is a bind mount of /usr/prog/etc, so this IS the live file
# dropbear authenticates against.
awk -v h="$H" 'BEGIN{FS=OFS=":"} $1=="root"{$2=h} {print}' \
awk -v h="$NEW_HASH" 'BEGIN{FS=OFS=":"} $1=="root"{$2=h} {print}' \
/usr/prog/etc/shadow > /usr/prog/etc/shadow.new &&
mv -f /usr/prog/etc/shadow.new /usr/prog/etc/shadow
chmod 600 /usr/prog/etc/shadow
+5 -4
View File
@@ -14,10 +14,11 @@ mkdir -p "$MODDIR" "$BACKUP"
echo "=== mod install $STAMP ==="
# app_startup.sh is deliberately NOT in this list: the mod replaces
# firmwareExe instead, so the stock boot scripts are never modified.
for f in /usr/prog/klipper/start.sh /usr/prog/etc/passwd /usr/prog/etc/shadow; do
if [ -e "$f" ]; then
d="$BACKUP`dirname $f`"
mkdir -p "$d" && cp -a "$f" "$d/" && echo "backed up $f"
for file in /usr/prog/klipper/start.sh /usr/prog/etc/passwd \
/usr/prog/etc/shadow; do
if [ -e "$file" ]; then
dest="$BACKUP`dirname $file`"
mkdir -p "$dest" && cp -a "$file" "$dest/" && echo "backed up $file"
fi
done
# NOTE: we deliberately do NOT try to back up the previous firmwareExe here.
+3 -3
View File
@@ -54,8 +54,8 @@ fi
# is the one step Klipper cannot start without, so do not depend on a lookup.
# LD_LIBRARY_PATH still matters: this interpreter does not start without it,
# which is exactly how moonrakerDaemon used to fail.
FF_PY=/usr/prog/Python-3.8.2/bin/python3
[ -x "$FF_PY" ] || FF_PY=python3
FF_PYTHON=/usr/prog/Python-3.8.2/bin/python3
[ -x "$FF_PYTHON" ] || FF_PYTHON=python3
#
# Test -f, not -x. The script is handed to the interpreter by path, so its
# execute bit is irrelevant -- and it is not always set: the payload carries
@@ -71,7 +71,7 @@ FF_PY=/usr/prog/Python-3.8.2/bin/python3
if [ "${FF_SKIP_MCU_BRINGUP:-0}" = 1 ]; then
echo "start.sh: MCU bring-up already done by the caller"
elif [ -f /usr/data/anvil/bin/ff_mcu_bringup.py ]; then
"$FF_PY" /usr/data/anvil/bin/ff_mcu_bringup.py \
"$FF_PYTHON" /usr/data/anvil/bin/ff_mcu_bringup.py \
|| echo "start.sh: MCU bring-up reported a problem ($?)"
else
echo "start.sh: ff_mcu_bringup.py missing -- the toolhead boards are not brought up"
+14 -14
View File
@@ -16,26 +16,26 @@ OPTION = re.compile(r"^([^:=\s][^:=]*)\s*[:=]\s*(.*)$")
def sections(path):
"""Yield (section_name, {option: value}) the way Klipper's parser sees it."""
cur, opts = None, {}
section, options = None, {}
key = None
for raw in open(path, encoding="utf-8", errors="replace"):
line = raw.rstrip("\n")
if not line.strip() or line.lstrip().startswith("#"):
continue
m = SECTION.match(line.strip())
if m:
if cur:
yield cur, opts
cur, opts, key = m.group(1), {}, None
header = SECTION.match(line.strip())
if header:
if section:
yield section, options
section, options, key = header.group(1), {}, None
continue
if cur is None:
if section is None:
continue
if line[:1] in " \t" and key: # continuation line
opts[key] += "\n" + line
options[key] += "\n" + line
continue
m = OPTION.match(line)
if m:
key = m.group(1).strip()
opts[key] = m.group(2)
if cur:
yield cur, opts
option = OPTION.match(line)
if option:
key = option.group(1).strip()
options[key] = option.group(2)
if section:
yield section, options
+13 -13
View File
@@ -44,9 +44,9 @@ set -a
if [ -f "$1" ]; then . "$1" || exit 91; fi
if [ -f "$2" ]; then . "$2" || exit 92; fi
set +a
for k in %s; do
eval "v=\${$k-}"
printf '%%s=%%s\0' "$k" "$v"
for key in %s; do
eval "value=\${$key-}"
printf '%%s=%%s\0' "$key" "$value"
done
''' % " ".join(WANTED)
@@ -66,31 +66,31 @@ class Config:
config_env = os.environ.get("CONFIG_ENV") or str(root / "config.env")
test_env = os.environ.get("TEST_ENV") or str(root / "test.env")
proc = subprocess.run(
dumped = subprocess.run(
["bash", "-c", _DUMP, "ffsim", config_env, test_env],
cwd=str(root), capture_output=True, text=True)
if proc.returncode != 0:
if dumped.returncode != 0:
# A syntax error in config.env is a BROKEN harness, not a missing
# precondition. The shell version could not tell the two apart:
# the half-sourced file left STOCK_TGZ_* empty, the launcher
# concluded there was nothing to test, and it reported a clean
# skip. Naming the file that failed is the entire point.
which = {91: config_env, 92: test_env}.get(proc.returncode)
which = {91: config_env, 92: test_env}.get(dumped.returncode)
raise Fail("could not read %s:\n%s"
% (which or ("%s / %s" % (config_env, test_env)),
proc.stderr.strip()))
dumped.stderr.strip()))
values = {}
for item in proc.stdout.split("\0"):
for item in dumped.stdout.split("\0"):
if "=" in item:
k, _, v = item.partition("=")
values[k] = v
key, _, value = item.partition("=")
values[key] = value
# The environment wins -- see the module docstring. Being SET is what
# counts, so this tests membership rather than truthiness.
for k in OVERRIDABLE:
if k in os.environ:
values[k] = os.environ[k]
for key in OVERRIDABLE:
if key in os.environ:
values[key] = os.environ[key]
return cls(values, root, config_env, test_env)
+6 -6
View File
@@ -45,7 +45,7 @@ def extract_rootfs(config, on_output=None):
kernels = sorted(outer.glob("kernel-*.tar.xz"))
if not kernels:
raise Fail("no kernel-*.tar.xz in the package (a --slim build has none)")
kern = kernels[0]
kernel_tarball = kernels[0]
kerndir = root / "work" / "kern"
rootfs = root / "work" / "rootfs"
@@ -54,7 +54,7 @@ def extract_rootfs(config, on_output=None):
_rmtree(d)
kerndir.mkdir(parents=True)
with tarfile.open(str(kern)) as tf:
with tarfile.open(str(kernel_tarball)) as tf:
_extract_all(tf, kerndir)
squash = None
@@ -62,15 +62,15 @@ def extract_rootfs(config, on_output=None):
squash = path
break
if squash is None:
raise Fail("no rootfs.squashfs inside %s" % kern.name)
raise Fail("no rootfs.squashfs inside %s" % kernel_tarball.name)
if on_output:
on_output(">> %s" % squash.name)
proc = subprocess.run(
completed = subprocess.run(
["unsquashfs", "-q", "-d", str(rootfs), str(squash)],
capture_output=True, text=True)
if proc.returncode != 0:
raise Fail("unsquashfs failed:\n%s" % proc.stderr.strip())
if completed.returncode != 0:
raise Fail("unsquashfs failed:\n%s" % completed.stderr.strip())
_rmtree(kerndir)
if not (rootfs / "bin").is_dir():
+16 -16
View File
@@ -79,12 +79,12 @@ class Replica:
"in test.env to skip it.")
image = "creator5-printer-sim"
ctx = config.root / "test" / "integration" / "printer"
build_dir = config.root / "test" / "integration" / "printer"
# Always rebuild: a cache hit takes about a second, and a stale image
# silently testing yesterday's harness is not a trade worth making.
built = subprocess.run(
[docker, "build", "-q", "-t", image,
"-f", str(ctx / "Dockerfile"), str(ctx)],
"-f", str(build_dir / "Dockerfile"), str(build_dir)],
capture_output=True, text=True)
if built.returncode != 0:
raise Fail("could not build %s:\n%s" % (image, built.stderr.strip()))
@@ -101,7 +101,7 @@ class Replica:
"""
packages = packages or {}
stage = stage or self.stage_dir()
cfg = self.config
config = self.config
argv = [self.docker, "run", "--rm", "-i", "--privileged"]
if not self.prebuilt:
@@ -111,7 +111,7 @@ class Replica:
"-v", "%s/pkgs:/pkgs:ro" % stage,
"-v", "%s/case.sh:/case.sh:ro" % stage,
"-v", "%s/payload:/payload:ro" % self.root,
"-e", "FF_KEY=%s" % cfg.ff_key,
"-e", "FF_KEY=%s" % config.ff_key,
"-e", "BASE_PKG=%s" % ("/pkgs/base.tgz" if base_pkg else ""),
"-e", "PKGS=%s" % "".join(" %s=/pkgs/%s" % (n, n) for n in packages),
]
@@ -119,16 +119,16 @@ class Replica:
# A real /usr/prog taken off a printer. Only for a locally built
# replica: a prebuilt image already has one baked in, and mounting
# over it would replace the genuine tree with an older copy.
dump = cfg.get("PROG_DUMP")
if not self.prebuilt and dump and os.path.exists(dump):
dump_abs = os.path.abspath(dump)
prog_dump = config.get("PROG_DUMP")
if not self.prebuilt and prog_dump and os.path.exists(prog_dump):
dump_abs = os.path.abspath(prog_dump)
argv += ["-e", "PROG_DUMP=/progdump", "-v", "%s:/progdump:ro" % dump_abs]
else:
argv += ["-e", "PROG_DUMP="]
argv += [
"-e", "PROG_MB=%s" % cfg.get("PROG_MB"),
"-e", "DATA_MB=%s" % cfg.get("DATA_MB"),
"-e", "PROG_MB=%s" % config.get("PROG_MB"),
"-e", "DATA_MB=%s" % config.get("DATA_MB"),
"-e", "SIM_VERBOSE=%s" % os.environ.get("SIM_VERBOSE", "0"),
"-e", "USB_STICK=%s" % ("1" if usb_stick else "0"),
self.image, "/case.sh",
@@ -170,14 +170,14 @@ class Replica:
shutil.copy(base_pkg, str(stage / "pkgs" / "base.tgz"))
argv = self.command(case, packages, base_pkg, usb_stick, stage)
proc = subprocess.run(argv, capture_output=True, text=True)
body = (proc.stdout or "") + (proc.stderr or "")
if on_output and body.strip():
on_output(body)
if proc.returncode != 0:
completed = subprocess.run(argv, capture_output=True, text=True)
output = (completed.stdout or "") + (completed.stderr or "")
if on_output and output.strip():
on_output(output)
if completed.returncode != 0:
raise Fail("%s exited %d" % (os.path.basename(case),
proc.returncode))
return body
completed.returncode))
return output
finally:
# The staged packages are ~80MB each.
shutil.rmtree(str(stage), ignore_errors=True)
+4 -4
View File
@@ -25,7 +25,7 @@ skip() { echo " SKIP $*"; }
PY=/usr/prog/Python-3.8.2/bin/python3
MR=/usr/prog/moonraker/moonraker
MR_MAIN=/usr/prog/moonraker/moonraker/moonraker/moonraker.py
MOONRAKER_MAIN=/usr/prog/moonraker/moonraker/moonraker/moonraker.py
S60=/tmp/payload/init.d/S60web
[ -x "$PY" ] || { bad "no interpreter at $PY"; exit 1; }
@@ -100,9 +100,9 @@ fi
# 3. S60web now starts moonraker itself, so the entry point it names has to
# be there and the tools it uses have to exist. A path that is wrong here
# means no web UI at all, and the failure would be a silent one.
[ -f "$MR_MAIN" ] \
&& ok "the entry point S60web names is present ($MR_MAIN)" \
|| bad "no moonraker.py at $MR_MAIN"
[ -f "$MOONRAKER_MAIN" ] \
&& ok "the entry point S60web names is present ($MOONRAKER_MAIN)" \
|| bad "no moonraker.py at $MOONRAKER_MAIN"
command -v start-stop-daemon >/dev/null 2>&1 \
&& ok "start-stop-daemon is available for the pidfile" \
+2 -2
View File
@@ -91,7 +91,7 @@ def test_the_two_things_the_daemon_did_are_kept():
# moonraker's temporary files -- uploads included -- off /tmp, which is a
# ramdisk here. Dropping either was the reason not to do this before.
text = code(S60WEB)
assert "start-stop-daemon -S" in text and "-p $MR_PID" in text
assert "start-stop-daemon -S" in text and "-p $MOONRAKER_PID" in text
assert "start-stop-daemon -K" in text
assert "TMPDIR" in text
@@ -111,4 +111,4 @@ def test_a_dead_moonraker_is_reported_in_the_boot_log():
# opens a browser. The boot log is the only place that can say so.
text = code(S60WEB)
assert "moonraker_check" in text
assert "moonraker.log" in text or "MR_LOG" in text
assert "moonraker.log" in text or "MOONRAKER_LOG" in text
+98 -89
View File
@@ -54,29 +54,30 @@ def indented(reporter):
# ------------------------------------------------------------ static checks
def check_shell_syntax(r):
def check_shell_syntax(reporter):
"""One line per check, not one per file: 25 green lines saying "syntax ok"
hide the two that matter."""
targets = []
for pattern in ("bin/*.sh", "payload/*.sh", "payload/init.d/S*",
"payload/firmwareExe", "test/integration/printer/*.sh"):
targets += sorted(ROOT.glob(pattern))
bad = []
for f in targets:
if not f.is_file():
broken = []
for script in targets:
if not script.is_file():
continue
proc = subprocess.run(["bash", "-n", str(f)],
capture_output=True, text=True)
if proc.returncode != 0:
bad.append(f.relative_to(ROOT))
r._say(" %s\n" % proc.stderr.strip())
if bad:
raise Fail("syntax errors in: %s" % " ".join(str(b) for b in bad))
parsed = subprocess.run(["bash", "-n", str(script)],
capture_output=True, text=True)
if parsed.returncode != 0:
broken.append(script.relative_to(ROOT))
reporter._say(" %s\n" % parsed.stderr.strip())
if broken:
raise Fail("syntax errors in: %s"
% " ".join(str(path) for path in broken))
if not targets:
raise Fail("no scripts found to parse -- the globs are wrong")
def check_no_bashisms(r):
def check_no_bashisms(reporter):
"""The payload runs on the printer's busybox ash, which is not bash.
shellcheck's dash dialect knows every "not supported in POSIX sh"
@@ -96,24 +97,26 @@ def check_no_bashisms(r):
# ash-compatibility check silently.
if not targets:
raise Fail("no payload scripts found to check -- has payload/ moved?")
proc = subprocess.run(["shellcheck", "-s", "dash", "-f", "gcc"] + targets,
capture_output=True, text=True)
hits = [ln for ln in (proc.stdout or "").split("\n")
if "SC3" in ln or "SC2039" in ln]
checked = subprocess.run(
["shellcheck", "-s", "dash", "-f", "gcc"] + targets,
capture_output=True, text=True)
hits = [line for line in (checked.stdout or "").split("\n")
if "SC3" in line or "SC2039" in line]
# shellcheck exits 1 for findings (which `hits` covers) but also for a
# usage or file error, which would otherwise pass unnoticed.
if not hits and proc.returncode not in (0, 1):
if not hits and checked.returncode not in (0, 1):
raise Fail("shellcheck failed to run (exit %d): %s"
% (proc.returncode, (proc.stderr or "").strip()[:200]))
% (checked.returncode,
(checked.stderr or "").strip()[:200]))
if hits:
for ln in hits[:10]:
r._say(" %s\n" % ln)
reporter._say(" %s\n" % ln)
raise Fail("bashisms in the payload")
# ------------------------------------------------------------------- pytest
def run_pytest(r):
def run_pytest(reporter):
"""Counts come from the JUnit XML, not from reading the summary line.
The shell version grepped stdout for "N skipped". That is the same
@@ -121,15 +124,15 @@ def run_pytest(r):
is just as wrong here even though pytest is well behaved about it.
"""
with tempfile.TemporaryDirectory() as tmp:
xml = os.path.join(tmp, "results.xml")
proc = subprocess.run(
results_xml = os.path.join(tmp, "results.xml")
completed = subprocess.run(
[sys.executable, "-m", "pytest", "./test/integration", "-q",
"--junitxml=" + xml],
"--junitxml=" + results_xml],
capture_output=True, text=True, cwd=str(ROOT))
if not os.path.exists(xml):
if not os.path.exists(results_xml):
raise Fail("pytest produced no results:\n%s"
% (proc.stdout or proc.stderr)[-2000:])
suite = ET.parse(xml).getroot()
% (completed.stdout or completed.stderr)[-2000:])
suite = ET.parse(results_xml).getroot()
if suite.tag == "testsuites":
suite = suite.find("testsuite")
total = int(suite.get("tests", 0))
@@ -156,9 +159,9 @@ def run_pytest(r):
# was written to end (see test-abi.sh in docs/testing.md).
if not total:
raise Fail("pytest collected no tests (exit %d) -- collection is "
"broken, or the suite has moved" % proc.returncode)
"broken, or the suite has moved" % completed.returncode)
if failures:
r._say((proc.stdout or "")[-4000:])
reporter._say((completed.stdout or "")[-4000:])
raise Fail("pytest: %d failed, %d passed, %d skipped"
% (failures, passed, skipped))
if skipped:
@@ -168,16 +171,16 @@ def run_pytest(r):
# "pytest skipped" can be checked against what SHOULD be unrunnable
# here rather than taken on trust.
for line in names:
r._say(" %s\n" % line)
reporter._say(" %s\n" % line)
raise Skip("%d passed, %d skipped: %s"
% (passed, skipped, "; ".join(n.split(" -- ")[0]
for n in names)))
r._say(" %d passed\n" % passed)
reporter._say(" %d passed\n" % passed)
# ------------------------------------------- packaging, on a synthetic stock
def make_fixture(r, tmp):
def make_fixture(reporter, tmp):
"""Build the synthetic stock package and the throwaway config for it.
Returns the environment the build steps should run under. Each of those
@@ -188,11 +191,12 @@ def make_fixture(r, tmp):
# Inside the repo: the replica starts sibling containers through the
# docker socket, and those mounts are resolved by the host daemon, where
# a path under this container's /tmp does not exist.
r.run(["./test/integration/make-stock-fixture.sh", str(fxdir)], cwd=ROOT)
reporter.run(["./test/integration/make-stock-fixture.sh", str(fxdir)],
cwd=ROOT)
fixture = fxdir / "Creator5Pro-stock-fixture.tgz"
if not fixture.is_file():
raise Fail("make-stock-fixture.sh produced no %s" % fixture.name)
stock_package = fxdir / "Creator5Pro-stock-fixture.tgz"
if not stock_package.is_file():
raise Fail("make-stock-fixture.sh produced no %s" % stock_package.name)
# Stand-ins for Mainsail and HelixScreen. The real ones are a 3MB zip and
# a 60MB tarball; the tests must not need the network, but they DO need
@@ -218,24 +222,25 @@ def make_fixture(r, tmp):
# verify gate then reads is a stump: no init.d, no HelixScreen, no
# Moonraker. That is exactly how it failed in CI while `make test` was
# green locally against a populated vendor/.
mr = assets / "mr" / "moonraker-fixture" / "moonraker"
(mr / "components").mkdir(parents=True, exist_ok=True)
(mr / "moonraker.py").write_text("# moonraker fixture\n")
(mr / "components" / "webcam.py").write_text('# "enabled"\n')
moonraker_fixture = assets / "mr" / "moonraker-fixture" / "moonraker"
(moonraker_fixture / "components").mkdir(parents=True, exist_ok=True)
(moonraker_fixture / "moonraker.py").write_text("# moonraker fixture\n")
(moonraker_fixture / "components" / "webcam.py").write_text(
'# "enabled"\n')
subprocess.run(["tar", "-czf", str(assets / "moonraker.tar.gz"),
"-C", str(assets / "mr"), "moonraker-fixture"], check=True)
# A throwaway config, passed through CONFIG_ENV. It used to overwrite
# ./config.env and copy it back afterwards, which put the config you
# edited one crashed run away from being replaced by a fixture one.
cfg = Path(tmp) / "config.env"
config_file = Path(tmp) / "config.env"
# BUILD_KLIPPER=stock, BY NAME: this job must not need the network, and
# the fork path needs the pinned tarball plus the ~203MB toolchain from
# vendor/. patch.sh no longer falls back silently -- KLIPPER_FORK="" here
# used to mean "quietly keep the stock tree", which is exactly how
# v20260824 shipped without its Klipper. The fork path is exercised where
# vendor/ exists: the printer-sim job and the release workflow.
cfg.write_text(
config_file.write_text(
"MOD_NAME=anvil\n"
"MOD_VER=ci\n"
'SW_VER=""\n'
@@ -248,26 +253,28 @@ def make_fixture(r, tmp):
"TARGET_MACHINE=Creator5Pro\n"
"ROOT_PW_HASH='$6$ci$abcdefghijklmnopqrstuvwxyz'\n"
"FF_KEY='FFP0331&*%%root'\n"
% (fixture, assets / "helixscreen.tar.gz", assets / "mainsail.zip",
% (stock_package, assets / "helixscreen.tar.gz",
assets / "mainsail.zip",
assets / "moonraker.tar.gz"))
return dict(os.environ, CONFIG_ENV=str(cfg), TARGET_MACHINE="Creator5Pro")
return dict(os.environ, CONFIG_ENV=str(config_file),
TARGET_MACHINE="Creator5Pro")
# --------------------------------------------------------------------- main
def main():
r = Reporter()
emit = indented(r)
reporter = Reporter()
emit = indented(reporter)
config = Config.load(ROOT)
r.hdr("shell syntax")
with r.gate("every script parses"):
check_shell_syntax(r)
reporter.hdr("shell syntax")
with reporter.gate("every script parses"):
check_shell_syntax(reporter)
r.hdr("no bashisms in the on-printer payload")
with r.gate("no bash-only constructs in the payload"):
check_no_bashisms(r)
reporter.hdr("no bashisms in the on-printer payload")
with reporter.gate("no bash-only constructs in the payload"):
check_no_bashisms(reporter)
stock = config.stock_for()
@@ -276,30 +283,30 @@ def main():
# both the config gate and the rootfs checks. This is also the unpack the
# replica gates need later.
if stock:
r.hdr("extracting the printer rootfs")
reporter.hdr("extracting the printer rootfs")
if (ROOT / "work" / "rootfs" / "bin").is_dir():
r.ok("rootfs already extracted")
reporter.ok("rootfs already extracted")
else:
with r.gate("extract the printer rootfs"):
r.run(["./bin/unpack.sh"], cwd=ROOT)
with reporter.gate("extract the printer rootfs"):
reporter.run(["./bin/unpack.sh"], cwd=ROOT)
gates.extract_rootfs(config, on_output=emit)
r.hdr("python checks")
with r.gate("pytest"):
run_pytest(r)
reporter.hdr("python checks")
with reporter.gate("pytest"):
run_pytest(reporter)
with tempfile.TemporaryDirectory() as tmp:
r.hdr("packaging, on a synthetic stock package")
reporter.hdr("packaging, on a synthetic stock package")
env = None
with r.gate("synthetic stock package"):
env = make_fixture(r, tmp)
with reporter.gate("synthetic stock package"):
env = make_fixture(reporter, tmp)
r.hdr("build on the fixture")
reporter.hdr("build on the fixture")
if env is None:
# The fixture is the input to every step below. Without it they
# would each fail for the same reason, which reads as four
# separate problems instead of one.
with r.gate("build on the fixture"):
with reporter.gate("build on the fixture"):
raise Fail("no fixture -- the steps below cannot run")
else:
# Stop at the first step that fails. These are a chain, not four
@@ -313,20 +320,22 @@ def main():
# Counted from here, not from zero: an earlier gate (pytest, say)
# may already have failed, and that is not a reason to skip the
# build.
failures_before = r.failed
failures_before = reporter.failed
broken = False
for step in ("unpack", "patch", "pack"):
with r.gate(step):
r.run(["./bin/%s.sh" % step], cwd=ROOT, env=env)
if r.failed > failures_before:
with reporter.gate(step):
reporter.run(["./bin/%s.sh" % step], cwd=ROOT, env=env)
if reporter.failed > failures_before:
broken = True
break
if not broken:
with r.gate("verify"):
pkgs = sorted((ROOT / "work" / "out").glob("Creator5Pro-*.tgz"))
if not pkgs:
with reporter.gate("verify"):
packages = sorted(
(ROOT / "work" / "out").glob("Creator5Pro-*.tgz"))
if not packages:
raise Fail("no package produced")
r.run(["./bin/verify.sh", str(pkgs[0])], cwd=ROOT, env=env)
reporter.run(["./bin/verify.sh", str(packages[0])],
cwd=ROOT, env=env)
# Throw away everything the fixture half built. bin/ hardcodes work/, so
# those packages land in the same work/out a real build uses -- and a
@@ -336,38 +345,38 @@ def main():
shutil.rmtree(str(ROOT / "work" / d), ignore_errors=True)
if not stock:
r.hdr("printer replica")
with r.gate("the printer replica"):
reporter.hdr("printer replica")
with reporter.gate("the printer replica"):
raise Skip("no stock package in config.env -- these are the gates "
"that decide whether a package bricks a printer")
else:
r.hdr("MCU bring-up runs on the printer's own Python")
with r.gate("mcu bring-up"):
reporter.hdr("MCU bring-up runs on the printer's own Python")
with reporter.gate("mcu bring-up"):
gates.mcu_bringup(config, on_output=emit)
with r.gate("boot screen"):
with reporter.gate("boot screen"):
gates.boot_screen(config, on_output=emit)
with r.gate("moonraker"):
with reporter.gate("moonraker"):
gates.moonraker(config, on_output=emit)
r.hdr("end-to-end update on the printer replica")
with r.gate("boot -> install -> re-install -> boot"):
reporter.hdr("end-to-end update on the printer replica")
with reporter.gate("boot -> install -> re-install -> boot"):
for step in ("unpack", "patch", "pack"):
r.run(["./bin/%s.sh" % step], cwd=ROOT)
pkgs = sorted((ROOT / "work" / "out").glob("*-*.tgz"))
if not pkgs:
reporter.run(["./bin/%s.sh" % step], cwd=ROOT)
packages = sorted((ROOT / "work" / "out").glob("*-*.tgz"))
if not packages:
raise Fail("no package produced")
gates.install(config, str(pkgs[0]), on_output=emit)
gates.install(config, str(packages[0]), on_output=emit)
r.hdr("recovery: a stock package reverts the mod")
with r.gate("install mod -> flash stock -> back to stock"):
pkgs = sorted((ROOT / "work" / "out").glob("*-*.tgz"))
if not pkgs:
reporter.hdr("recovery: a stock package reverts the mod")
with reporter.gate("install mod -> flash stock -> back to stock"):
packages = sorted((ROOT / "work" / "out").glob("*-*.tgz"))
if not packages:
raise Fail("no package produced")
gates.roundtrip(config, str(pkgs[0]), stock, on_output=emit)
gates.roundtrip(config, str(packages[0]), stock, on_output=emit)
return r.summary()
return reporter.summary()
if __name__ == "__main__":