Fix what a full test round of today's releases found: a ticking single clock, a switch that works in standby, no taps from a swipe (0.2.78)
A test and code-analysis round of 0.2.72 to 0.2.76 (suites at every tag, sanitizers, ESP32 and GitHub builds, host harnesses on both boards, the editor in a browser, best practice checked against ESPHome, LVGL, Home Assistant and Vue) found these, fixed here with Max's choices for the product questions: - Firmware 0.2.65: the second hand of a single analog clock moves again; on a page that is one full on/off tile the waking push also switches it; on the Guition a flick is never a tap and a finger that slides off a tile doesn't tap it; Previous/Next react to a firm press; the same Go to page tile may sit on several pages; a layout the screen has no memory for is refused instead of crashing; 64-bit redraw mask for 48 tiles; Montserrat 14 no longer built. - App: Save refuses a layout too long for one message or too big for eight pages; an offline screen keeps its firmware version from the device registry; HA refusals reach the editor; 128 KB requests; changelog out of the live stream; cached hashed assets; tini as init and a clean stop; changelog parsing without asterisks or crashes. - Editor: clicking the open screen keeps unsaved edits, edits during a save stay unsaved, a Page row and phone dragging to other pages, repeated page tiles, limits from the server, HA's error messages, a type-checked build. - tools/check.sh + GitHub Actions + a CYD flash budget; docs match 48 tiles and the new editor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+290
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env bash
|
||||
# The release checks of docs/RELEASING.md step 2 in one command, the same on a laptop and in CI
|
||||
# (.github/workflows/ci.yml, app 0.2.78). Runs from any folder: every path is taken from this repository.
|
||||
#
|
||||
# tools/check.sh the Python tests, every tests/*.cpp, both generators with --check, the editor's
|
||||
# tests, types and build, and whether the editor bundle in Git equals that build
|
||||
# tools/check.sh --firmware compiles both board profiles and applies the CYD's flash budget
|
||||
# tools/check.sh --all both
|
||||
# --baseline BYTES with --firmware: the CYD image of the last release, to print the growth
|
||||
#
|
||||
# Environment:
|
||||
# PYTHON python3 by default; needs aiohttp, PyYAML, Pillow and fontTools (.venv-portal/bin/python has them)
|
||||
# CXX clang++ by default
|
||||
# ESPHOME the ESPHome command, esphome by default ("python -m esphome", or a newer Device Builder's)
|
||||
# ESPHOME_DATA_DIR where the firmware builds go, .esphome/check by default: apart from the bench profiles' own
|
||||
# build folders, so a check build never replaces the firmware.bin of a screen's profile
|
||||
#
|
||||
# Every check prints PASS, WARN or FAIL; a failing check shows the end of its output. Exit status 1 when one failed.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
PYTHON=${PYTHON:-python3}
|
||||
CXX=${CXX:-clang++}
|
||||
read -r -a ESPHOME_CMD <<< "${ESPHOME:-esphome}"
|
||||
|
||||
usage() { sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d; s/^# \{0,1\}//'; }
|
||||
|
||||
want_fast=1 want_firmware=0 saw_firmware=0 saw_all=0 baseline=""
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--firmware) saw_firmware=1 ;;
|
||||
--all) saw_all=1 ;;
|
||||
--baseline)
|
||||
baseline=${2:-}
|
||||
[[ $baseline =~ ^[0-9]+$ ]] || { echo "--baseline needs a size in bytes, such as 1655584" >&2; exit 2; }
|
||||
shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1 (see tools/check.sh --help)" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if ((saw_firmware || saw_all)); then want_firmware=1; fi
|
||||
if ((saw_firmware && !saw_all)); then want_fast=0; fi
|
||||
|
||||
WORK=$(mktemp -d "${TMPDIR:-/tmp}/esp-screens-check.XXXXXX")
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
passed=0 count=0 last_ok=0
|
||||
failed=() warned=()
|
||||
note() { printf '%s' "$*" > "$WORK/note"; } # a short result for the PASS line, such as "17/17"
|
||||
warn() { printf '%s\n' "$*" >> "$WORK/warn"; } # passes, but someone has to look
|
||||
|
||||
# run NAME COMMAND...: one check. Its output goes to a log that is shown when it fails; last_ok says how it went.
|
||||
# The command runs in a subshell, so its cd stays there. set -e doesn't reach into it (it runs as `cmd || ...`), so
|
||||
# every check function stops on its own errors with `|| return 1`.
|
||||
run() {
|
||||
local name=$1 log="$WORK/log-$((count += 1)).txt" start=$SECONDS status=0 detail=""
|
||||
shift
|
||||
rm -f "$WORK/note" "$WORK/warn"
|
||||
printf '%-40s ' "$name"
|
||||
("$@") > "$log" 2>&1 || status=$?
|
||||
[[ -s $WORK/note ]] && detail=" $(cat "$WORK/note")"
|
||||
if ((status != 0)); then
|
||||
printf 'FAIL%s (%ss)\n' "$detail" $((SECONDS - start))
|
||||
# The last 200 lines, each cut at 1000 characters: an assertion can quote a whole header.
|
||||
tail -n 200 "$log" | awk '{ if (length($0) > 1000) $0 = substr($0, 1, 1000) " ..."; print " | " $0 }'
|
||||
failed+=("$name")
|
||||
last_ok=0
|
||||
elif [[ -s $WORK/warn ]]; then
|
||||
printf 'WARN%s (%ss)\n' "$detail" $((SECONDS - start))
|
||||
sed 's/^/ ! /' "$WORK/warn"
|
||||
warned+=("$name")
|
||||
last_ok=1
|
||||
else
|
||||
printf 'PASS%s (%ss)\n' "$detail" $((SECONDS - start))
|
||||
passed=$((passed + 1))
|
||||
last_ok=1
|
||||
fi
|
||||
}
|
||||
|
||||
skip() { printf '%-40s SKIP (%s)\n' "$1" "$2"; }
|
||||
|
||||
# ---- The fast checks ----
|
||||
|
||||
python_packages() {
|
||||
"$PYTHON" - <<'EOF' || return 1
|
||||
import importlib.util, sys
|
||||
wanted = {'aiohttp': 'aiohttp', 'yaml': 'PyYAML', 'PIL': 'Pillow', 'fontTools': 'fonttools'}
|
||||
missing = [package for module, package in wanted.items() if importlib.util.find_spec(module) is None]
|
||||
print(sys.executable, sys.version.split()[0])
|
||||
# Without them the server and camera tests skip themselves, and a skipped test proves nothing.
|
||||
if missing:
|
||||
sys.exit('Missing: ' + ' '.join(missing) + '. pip install them, or run with PYTHON=.venv-portal/bin/python')
|
||||
EOF
|
||||
note "$("$PYTHON" -c 'import sys; print(sys.version.split()[0])' 2>/dev/null || true)"
|
||||
}
|
||||
|
||||
python_tests() {
|
||||
local out="$WORK/python.txt" status=0
|
||||
(cd "$ROOT" && "$PYTHON" -m unittest discover -s tests) > "$out" 2>&1 || status=$?
|
||||
cat "$out"
|
||||
note "$(grep -Eo '^Ran [0-9]+ tests?' "$out" | tail -n 1 | sed 's/^Ran //')$(grep -Eo 'skipped=[0-9]+' "$out" | tail -n 1 | sed 's/^/, /')"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
cpp_tests() {
|
||||
local test name total=0 good=0
|
||||
cd "$ROOT" || return 1
|
||||
for test in tests/*.cpp; do
|
||||
name=$(basename "$test" .cpp)
|
||||
total=$((total + 1))
|
||||
if "$CXX" -std=c++17 -Wall -Wextra -Werror -I. "$test" -o "$WORK/$name" && "$WORK/$name"; then
|
||||
good=$((good + 1))
|
||||
echo "PASS $name"
|
||||
else
|
||||
echo "FAIL $name"
|
||||
fi
|
||||
done
|
||||
note "$good/$total"
|
||||
((total > 0 && good == total))
|
||||
}
|
||||
|
||||
packages_current() { cd "$ROOT" && "$PYTHON" tools/generate_packages.py --check; }
|
||||
icons_current() { cd "$ROOT" && "$PYTHON" tools/generate_icons.py --check; }
|
||||
editor_install() { cd "$ROOT/web" && npm ci --no-audit --no-fund; }
|
||||
editor_tests() { cd "$ROOT/web" && npm test; }
|
||||
editor_types() { cd "$ROOT/web" && npm run check; }
|
||||
editor_build() { cd "$ROOT/web" && npm run build; }
|
||||
|
||||
# The add-on serves the committed bundle, so it must be what the committed web/src builds to (Vite names every file
|
||||
# after a hash of its content). Compared with Git's index, so a freshly built bundle that is staged passes.
|
||||
editor_bundle() {
|
||||
local static=screen_manager/app/static untracked
|
||||
cd "$ROOT" || return 1
|
||||
git rev-parse --is-inside-work-tree > /dev/null || { echo "Not a Git work tree: can't compare the bundle"; return 1; }
|
||||
untracked=$(git ls-files --others --exclude-standard -- "$static")
|
||||
if git diff --quiet -- "$static" && [[ -z $untracked ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo "A fresh build of web/ differs from $static in Git. Commit the new build (it is in place now):"
|
||||
git diff --stat -- "$static"
|
||||
[[ -z $untracked ]] || printf 'new: %s\n' $untracked
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---- Firmware ----
|
||||
|
||||
esphome_version() {
|
||||
local pinned running
|
||||
pinned=$(sed -n 's|^FROM ghcr.io/esphome/esphome:||p' "$ROOT/screen_manager/Dockerfile")
|
||||
running=$("${ESPHOME_CMD[@]}" version | sed -n 's/^Version: //p')
|
||||
echo "ESPHome $running (${ESPHOME_CMD[*]}); the add-on ships $pinned"
|
||||
note "$running"
|
||||
[[ -n $running ]] || return 1
|
||||
[[ $running == "$pinned" ]] || warn "The add-on ships ESPHome $pinned: measure the release budget on that one too."
|
||||
}
|
||||
|
||||
# The builds users get come from the YAML core.installation_yaml() writes: the board's package plus the device's own
|
||||
# keys, the Wi-Fi fallback access point and captive_portal. The board profiles carry all of that (with !secret), so a
|
||||
# copy of each profile compiles in a temporary folder with placeholder secrets of the same length as real ones, next
|
||||
# to links to this tree's components and fonts: this commit's code, never GitHub's main, never the real secrets.yaml.
|
||||
prepare_profiles() {
|
||||
local config="$WORK/config" board file
|
||||
mkdir -p "$config" && ln -s "$ROOT/components" "$config/components" && ln -s "$ROOT/fonts" "$config/fonts" || return 1
|
||||
cat > "$config/secrets.yaml" <<'EOF' || return 1
|
||||
# Placeholders for the check builds (tools/check.sh); nothing here is a real key.
|
||||
wifi_ssid: "check-wifi"
|
||||
wifi_password: "check-wifi-password"
|
||||
api_encryption_key: "Y2hlY2stYnVpbGQtcGxhY2Vob2xkZXIta2V5LTMyYnk="
|
||||
ota_password: "check-build-ota-password-0000000"
|
||||
ap_password: "check-ap-passwd0"
|
||||
EOF
|
||||
for board in cyd guition; do
|
||||
file=$([[ $board == cyd ]] && echo home-like-2432s028.yaml || echo guition-4848s040.yaml)
|
||||
cp "$ROOT/$file" "$config/check-$board.yaml" || return 1
|
||||
if ! grep -q '^captive_portal:' "$config/check-$board.yaml" || ! grep -q '^ ap:' "$config/check-$board.yaml"; then
|
||||
echo "$file has no captive_portal: or wifi ap: any more, unlike the YAML users get; the flash figures would read low."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
echo "Check profiles in $config"
|
||||
}
|
||||
|
||||
compile_board() { # compile_board cyd|guition
|
||||
local board=$1
|
||||
cd "$WORK/config" || return 1
|
||||
"${ESPHOME_CMD[@]}" -s DEVICE_NAME "check-$board" -s DEVICE_FRIENDLY_NAME "Check $board" compile "check-$board.yaml" || return 1
|
||||
flash_report "$board"
|
||||
}
|
||||
|
||||
# flash_report BOARD [budget]: the image against its update slot, both read from the build, and on the CYD the growth
|
||||
# against --baseline and the budget of docs/RELEASING.md step 2. Exit 3 means over 90 %: it passes, but the release has
|
||||
# to say why.
|
||||
flash_report() {
|
||||
local board=$1 mode=${2:-} status=0 out="$WORK/flash-$1.txt" against=""
|
||||
[[ $board == cyd ]] && against=$baseline
|
||||
"$PYTHON" - "$ESPHOME_DATA_DIR/build/check-$board" "$mode" "$against" > "$out" 2>&1 <<'EOF' || status=$?
|
||||
import csv, sys
|
||||
from pathlib import Path
|
||||
|
||||
build, mode, baseline = Path(sys.argv[1]), sys.argv[2], sys.argv[3]
|
||||
|
||||
def size(text):
|
||||
text = text.strip().upper()
|
||||
scale = {'K': 1024, 'M': 1024 * 1024}.get(text[-1:], 1)
|
||||
return int(text[:-1] if scale > 1 else text, 0) * scale
|
||||
|
||||
rows = csv.reader(line for line in (build / 'partitions.csv').read_text().splitlines()
|
||||
if line.strip() and not line.lstrip().startswith('#'))
|
||||
slot = next((size(row[4]) for row in ([cell.strip() for cell in row] for row in rows)
|
||||
if len(row) >= 5 and row[1] == 'app' and (row[2] == 'ota_0' or row[0] == 'app0')), None)
|
||||
if not slot:
|
||||
sys.exit(f'No app0/ota_0 partition in {build / "partitions.csv"}')
|
||||
images = sorted(build.glob('.pioenvs/*/firmware.ota.bin')) or sorted(build.glob('.pioenvs/*/firmware.bin')) \
|
||||
or sorted(build.rglob('firmware.ota.bin'))
|
||||
if not images:
|
||||
sys.exit(f'No firmware.ota.bin under {build}')
|
||||
image = images[0].stat().st_size
|
||||
share = image / slot * 100
|
||||
line = f'{image:,} B of {slot:,} B = {share:.1f} % ({slot - image:,} B free)'
|
||||
delta = None
|
||||
if baseline:
|
||||
delta = image - int(baseline)
|
||||
line += f', {delta:+,} B against {int(baseline):,} B'
|
||||
print(line)
|
||||
if mode != 'budget':
|
||||
sys.exit(0)
|
||||
if share > 95:
|
||||
print('Over 95 %: never ship this; it leaves no room for ESPHome upgrades and users\' own overrides.')
|
||||
sys.exit(1)
|
||||
if share > 93:
|
||||
print('93-95 %: only fixes ship.')
|
||||
sys.exit(3)
|
||||
if share > 90:
|
||||
print('90-93 %: tight. The release states its flash delta; more than 8 KB needs a matching saving or Max\'s OK.'
|
||||
+ (f' This one grows {delta:,} B.' if delta is not None and delta > 8192 else ''))
|
||||
sys.exit(3)
|
||||
EOF
|
||||
cat "$out"
|
||||
note "$(head -n 1 "$out")"
|
||||
if ((status == 3)); then
|
||||
warn "$(tail -n +2 "$out")"
|
||||
return 0
|
||||
fi
|
||||
return "$status"
|
||||
}
|
||||
|
||||
cyd_budget() { flash_report cyd budget; }
|
||||
|
||||
# ---- Run ----
|
||||
|
||||
echo "ESP Screens checks in $ROOT"
|
||||
if ((want_fast)); then
|
||||
run "Python packages" python_packages
|
||||
run "Python tests" python_tests
|
||||
run "C++ tests" cpp_tests
|
||||
run "Packages match the profiles" packages_current
|
||||
run "Icons match tile_icons.py" icons_current
|
||||
run "Editor: npm ci" editor_install
|
||||
if ((last_ok)); then
|
||||
run "Editor: tests (Vitest)" editor_tests
|
||||
run "Editor: types (vue-tsc)" editor_types
|
||||
run "Editor: build" editor_build
|
||||
if ((last_ok)); then run "Editor: bundle in Git is fresh" editor_bundle; else skip "Editor: bundle in Git is fresh" "no build"; fi
|
||||
else
|
||||
skip "Editor: tests, types, build" "npm ci failed"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ((want_firmware)); then
|
||||
export ESPHOME_DATA_DIR=${ESPHOME_DATA_DIR:-$ROOT/.esphome/check}
|
||||
run "ESPHome" esphome_version
|
||||
if ((last_ok)); then run "Check profiles" prepare_profiles; fi
|
||||
if ((last_ok)); then
|
||||
# One after the other: parallel ESP-IDF builds race on PlatformIO's shared cache.
|
||||
run "Firmware: CYD" compile_board cyd
|
||||
if ((last_ok)); then run "CYD flash budget" cyd_budget; else skip "CYD flash budget" "no CYD build"; fi
|
||||
run "Firmware: Guition" compile_board guition
|
||||
else
|
||||
skip "Firmware builds" "ESPHome or the check profiles are missing"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "$passed passed, ${#warned[@]} warned, ${#failed[@]} failed"
|
||||
((${#warned[@]} == 0)) || printf 'WARN: %s\n' "${warned[@]}"
|
||||
if ((${#failed[@]})); then
|
||||
printf 'FAIL: %s\n' "${failed[@]}"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user