mirror of
https://github.com/Klipper4FlashForge/firmware.git
synced 2026-10-22 13:22:16 -05:00
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:
1 parent
73d967efb2
commit
4704ca0213
28 files changed
+1199
-1097
No files matched your search
+5
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user