From 05deb1ee7d9fd48a3e5e5f07ecaae816d12c48f4 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:35:13 -0400 Subject: [PATCH] feat(install): support Raspberry Pi OS Bookworm (Python 3.11) alongside Trixie (3.13) (#689) The installer and scripts/check_system_compatibility.sh share one set of OS rules (scripts/install/lib_os.sh): Bookworm (Debian 12, Python 3.11) and Trixie (Debian 13, Python 3.13) are supported, python3 older than 3.11 stops the install before anything changes, and dhcpcd gets a warning with directions. setcap targets /usr/bin/python3, the apt fallback honours the requirement floors, and the desktop check no longer misreads under pipefail. CI runs the unit and plugin-safety suites on 3.11 and 3.13 (tooling jobs on 3.13); mypy targets 3.11. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-version-check.yml | 2 +- .github/workflows/test.yml | 27 +- CHANGELOG.md | 28 ++ README.md | 7 +- docs/EMULATOR_SETUP_GUIDE.md | 4 +- docs/GETTING_STARTED.md | 9 +- docs/TROUBLESHOOTING.md | 44 +++ first_time_install.sh | 115 +++++-- mypy.ini | 9 +- requirements.txt | 2 +- scripts/check_system_compatibility.sh | 104 ++++-- scripts/install/README.md | 3 + scripts/install/lib_os.sh | 138 ++++++++ scripts/install_dependencies_apt.py | 82 +++-- test/test_install_os_support.py | 343 ++++++++++++++++++++ test/test_install_reboot_is_last.py | 4 +- web_interface/requirements.txt | 2 +- 17 files changed, 812 insertions(+), 111 deletions(-) create mode 100644 scripts/install/lib_os.sh create mode 100644 test/test_install_os_support.py diff --git a/.github/workflows/release-version-check.yml b/.github/workflows/release-version-check.yml index d9c6320a..c3c2303a 100644 --- a/.github/workflows/release-version-check.yml +++ b/.github/workflows/release-version-check.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: "3.12" + python-version: "3.13" # No dependencies: the script reads src/__init__.py and CHANGELOG.md only. - name: Assert the tag, CHANGELOG and src.__version__ agree diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f01615a..efb4f76b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,8 +14,14 @@ permissions: jobs: plugin-safety: - name: Plugin safety harness + unit tests + name: Plugin safety harness + unit tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + # The two Pythons the installer supports: Raspberry Pi OS Bookworm ships + # 3.11 and Trixie 3.13. + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.13"] env: # The bundled fixture plugin gives the harness at least one real plugin # to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a @@ -29,7 +35,7 @@ jobs: - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} cache: pip - name: Install dependencies @@ -43,8 +49,13 @@ jobs: pytest --no-cov test/plugins/ unit-tests: - name: Core unit tests + name: Core unit tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + # Bookworm's Python (3.11) and Trixie's (3.13); see plugin-safety. + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.13"] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -52,7 +63,7 @@ jobs: - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} cache: pip - name: Install dependencies @@ -84,7 +95,7 @@ jobs: - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: "3.12" + python-version: "3.13" cache: pip - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 @@ -123,7 +134,7 @@ jobs: - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: "3.12" + python-version: "3.13" # Downloads the pinned standalone Tailwind CLI (SHA-256 checked; no # Node), rebuilds static/v3/tailwind.css and plugin-frame.css from the @@ -142,7 +153,7 @@ jobs: - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: "3.12" + python-version: "3.13" cache: pip # The runtime requirements are installed so mypy sees the real types of @@ -181,7 +192,7 @@ jobs: - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - python-version: "3.12" + python-version: "3.13" # Stdlib only; exits 0 whatever it finds. - name: Report method-family drift across the nine scoreboards diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a800205..fb660b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,34 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Install + +- Raspberry Pi OS **Bookworm** (Debian 12, Python 3.11) is supported, + alongside **Trixie** (Debian 13, Python 3.13). The installer used to stop + on anything but Trixie. Which releases and Pythons are accepted now lives + in one place, `scripts/install/lib_os.sh`, which `first_time_install.sh` + and `scripts/check_system_compatibility.sh` both read, so the two can no + longer disagree (the compatibility check called Bookworm an error, and + still accepted Python 3.10, which the rgbmatrix bindings refuse). An + unsupported system gets plain directions to the right image; a `python3` + older than 3.11 stops the install before anything changes. +- The installer says up front when the Pi runs dhcpcd instead of + NetworkManager, and how to switch back: the web page's WiFi tab and the + `LEDMatrix-Setup` hotspot need NetworkManager. Not fatal, and it does not + switch the network stack itself, since that can cut the SSH session. +- The desktop check no longer misses a desktop install: `dpkg -l | grep -q` + under `pipefail` read a match as "not found". +- `cap_sys_nice` is set on the interpreter the services run + (`/usr/bin/python3`); it preferred `/usr/bin/python3.13` whenever it + existed. +- The Step 7 dependency fallback (`scripts/install_dependencies_apt.py`) no + longer accepts apt packages older than the pins -- Bookworm's Flask 2.2.2 + and Pillow 9.4, Trixie's Flask 3.1.1 and Pillow 11.1. The floors are read + from `web_interface/requirements.txt`, and pip is asked for `Pillow`, not + `PIL`. +- CI runs the unit and plugin-safety suites on Python 3.11 and 3.13 (was + 3.12); mypy targets 3.11. + ### Updates refresh the systemd units; new installs run the newest release - **Updates now install changed systemd units.** An update (Update Code, or diff --git a/README.md b/README.md index b642d423..56538dc1 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,11 @@ The system supports live, recent, and upcoming game information for multiple spo - **1GB models (Pi 3B / 3B+), the 512MB Pi Zero 2 W and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`. Once running, keep an eye on memory: see [docs/LOW_MEMORY_BOARDS.md](docs/LOW_MEMORY_BOARDS.md). +### Operating system +- **Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12)**, 64-bit recommended. Trixie is the current release and the one to pick for a new SD card; an existing Bookworm install works as it is, no upgrade needed. The installer checks this first and stops with directions on anything else (Bullseye and older, the desktop edition, other distributions). +- **Python**: whatever the OS ships, 3.13 on Trixie and 3.11 on Bookworm. Don't install a different Python; the installer and the services use the system `python3`. +- **Networking**: NetworkManager, the default on both. Choosing a WiFi network from the web page and the `LEDMatrix-Setup` hotspot need it; if you switched to dhcpcd in `raspi-config`, switch back (Advanced Options → Network Config → NetworkManager). + ### RGB Matrix Bonnet / HAT - [Adafruit RGB Matrix Bonnet/HAT](https://www.adafruit.com/product/3211) – supports one “chain” of horizontally connected displays - [Adafruit Triple LED Matrix Bonnet](https://www.adafruit.com/product/6358) – supports up to 3 vertical “chains” of horizontally connected displays *(use `regular` as hardware mapping)* @@ -249,7 +254,7 @@ These are not required and you can probably rig up something basic with stuff yo Step 2 Other -5. Then choose Raspbian OS (64-bit) Lite (Trixie) +5. Then choose Raspbian OS (64-bit) Lite (Trixie). Bookworm Lite (listed as Legacy) also works; see [Operating system](#operating-system) below Step 4 Trixie Lite 64 diff --git a/docs/EMULATOR_SETUP_GUIDE.md b/docs/EMULATOR_SETUP_GUIDE.md index f118dcb0..73872808 100644 --- a/docs/EMULATOR_SETUP_GUIDE.md +++ b/docs/EMULATOR_SETUP_GUIDE.md @@ -17,13 +17,13 @@ The LEDMatrix emulator allows you to run and test LEDMatrix displays on your com ## Prerequisites ### System Requirements -- Python 3.10 or higher +- Python 3.11 or higher (3.11 and 3.13 are tested) - Windows, macOS, or Linux - At least 2GB RAM (4GB recommended) - Internet connection for plugin downloads ### Required Software -- Python 3.10+ +- Python 3.11+ - pip (Python package manager) - Git (for plugin management) diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f41045e8..82a68e2c 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -15,6 +15,12 @@ This guide will help you set up your LEDMatrix display for the first time and ge - Power supply (5V, 4A minimum recommended) - MicroSD card (16GB minimum) +**Software:** +- Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12). Trixie + is the current release; Bookworm is listed as Legacy in Raspberry Pi + Imager. No other system is supported, and the installer says so up front. +- The OS's own Python: 3.13 on Trixie, 3.11 on Bookworm + **Network:** - WiFi network (or Ethernet cable) - Computer with web browser on same network @@ -28,7 +34,8 @@ This guide will help you set up your LEDMatrix display for the first time and ge There is no prebuilt SD card image — you install LEDMatrix onto stock Raspberry Pi OS Lite yourself: -1. Flash Raspberry Pi OS Lite to the MicroSD card (Raspberry Pi Imager) +1. Flash Raspberry Pi OS Lite (Trixie, or Bookworm) to the MicroSD card + (Raspberry Pi Imager) 2. Connect the LED matrix to your Raspberry Pi, insert the card, and power on 3. SSH into the Pi and run the one-shot installer: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 11b806d5..7721e70e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -84,6 +84,43 @@ python3 web_interface/start.py ### Installation & Build Issues +#### "This version of Raspberry Pi OS is not supported" + +LEDMatrix installs on Raspberry Pi OS Lite **Trixie** (Debian 13, Python +3.13) or **Bookworm** (Debian 12, Python 3.11). The installer checks +`/etc/os-release` before it changes anything and stops on anything else. + +**Check what you have:** +```bash +grep -E '^(PRETTY_NAME|VERSION_ID)=' /etc/os-release +python3 --version +``` + +**Solutions:** +- `VERSION_ID="11"` (Bullseye) or older: flash a new card with Raspberry Pi + Imager, choosing Raspberry Pi OS Lite (64-bit). Trixie is recommended; + Bookworm (Legacy) also works. An in-place upgrade from Bullseye is not + supported by Raspberry Pi and is not worth the risk. +- "Desktop environment detected": use the Lite image, not the desktop one. +- "python3 is Python 3.x; LEDMatrix needs Python 3.11 or newer": something + has replaced the system `python3`. Point it back at the OS's own Python + (`/usr/bin/python3` should be 3.11 on Bookworm, 3.13 on Trixie). +- `sudo bash scripts/check_system_compatibility.sh` runs the same checks + without installing anything. + +#### "This Pi manages its network with dhcpcd, not NetworkManager" + +A warning, not an error: the install carries on and the display works. But +choosing a WiFi network from the web page and the `LEDMatrix-Setup` hotspot +both need NetworkManager, the default on Bookworm and Trixie. It appears +when dhcpcd was selected in `raspi-config`. Switch back with a keyboard and +screen attached (or over Ethernet), since the WiFi connection drops briefly: + +```bash +sudo raspi-config # Advanced Options -> Network Config -> NetworkManager +sudo reboot +``` + #### Step 6 fails: "Failed building wheel for rgbmatrix" **Symptoms:** @@ -407,9 +444,16 @@ then restore only that file, for example 5. **Check required services:** ```bash + systemctl is-active NetworkManager # must say "active" sudo systemctl status hostapd sudo systemctl status dnsmasq ``` + On a fresh install `hostapd` shows as **masked**. That is expected, on + Bookworm and Trixie alike: Debian's hostapd package masks the service + when it is installed without a configuration, so the hotspot is brought + up through NetworkManager instead (look for `nmcli hotspot fallback` in + `journalctl -u ledmatrix-wifi-monitor`). If NetworkManager is not + active, see "This Pi manages its network with dhcpcd" above. 6. **Manually enable AP mode:** ```bash diff --git a/first_time_install.sh b/first_time_install.sh index 6d360440..a149fd8c 100755 --- a/first_time_install.sh +++ b/first_time_install.sh @@ -47,38 +47,51 @@ if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then echo "Raspberry Pi 5 detected — will verify RP1 library support." fi -# Check OS version - must be Raspberry Pi OS Lite (Trixie) +# Check OS version - must be Raspberry Pi OS Lite, Bookworm or Trixie. +# The rules live in scripts/install/lib_os.sh, shared with +# scripts/check_system_compatibility.sh. echo "" echo "Checking operating system requirements..." echo "----------------------------------------" OS_CHECK_FAILED=0 +OS_RELEASE="" -if [ -f /etc/os-release ]; then - . /etc/os-release - echo "Detected OS: $PRETTY_NAME" - echo "Version ID: ${VERSION_ID:-unknown}" - - # Check if it's Raspberry Pi OS or Debian - if [[ "$ID" != "raspbian" ]] && [[ "$ID" != "debian" ]]; then - echo "✗ ERROR: This script requires Raspberry Pi OS (raspbian/debian)" - echo " Detected OS ID: $ID" - OS_CHECK_FAILED=1 - fi - - # Check if it's Debian 13 (Trixie) - if [ "${VERSION_ID:-0}" != "13" ]; then - echo "✗ ERROR: This script requires Raspberry Pi OS Lite (Trixie) - Debian 13" - echo " Detected version: ${VERSION_ID:-unknown}" - echo " Please upgrade to Raspberry Pi OS Lite (Trixie) before continuing" - OS_CHECK_FAILED=1 +OS_LIB="$(cd "$(dirname "$0")" && pwd)/scripts/install/lib_os.sh" +if [ ! -f "$OS_LIB" ]; then + echo "✗ ERROR: $OS_LIB is missing, so the operating system cannot be checked." + echo " Your LEDMatrix download is incomplete. Download it again and re-run this script:" + echo " git clone https://github.com/ChuckBuilds/LEDMatrix.git" + exit 1 +fi +# shellcheck source=scripts/install/lib_os.sh +. "$OS_LIB" + +if [ -r "$LM_OS_RELEASE_FILE" ]; then + echo "Detected OS: $(lm_os_field PRETTY_NAME)" + OS_VERSION_ID=$(lm_os_field VERSION_ID) + echo "Version ID: ${OS_VERSION_ID:-unknown}" + + if OS_RELEASE=$(lm_os_release); then + echo "✓ $(lm_release_label "$OS_RELEASE") detected" else - echo "✓ Debian 13 (Trixie) detected" + OS_ID=$(lm_os_field ID) + if [[ "$OS_ID" != "raspbian" ]] && [[ "$OS_ID" != "debian" ]]; then + echo "✗ ERROR: This script requires Raspberry Pi OS (raspbian/debian)" + echo " Detected OS ID: ${OS_ID:-unknown}" + else + echo "✗ ERROR: This version of Raspberry Pi OS is not supported" + echo " Detected version: ${OS_VERSION_ID:-unknown}" + echo " Supported: Trixie (Debian 13) and Bookworm (Debian 12)" + fi + OS_CHECK_FAILED=1 fi - + # Check if it's the Lite version (no desktop environment) # Check for desktop packages or desktop services DESKTOP_DETECTED=0 - if dpkg -l | grep -qE "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde"; then + # grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE, + # and pipefail turns a found desktop into "not found". + if dpkg -l | grep -E "^ii.*raspberrypi-ui-mods|^ii.*lxde|^ii.*xfce|^ii.*gnome|^ii.*kde" >/dev/null; then DESKTOP_DETECTED=1 fi if systemctl list-units --type=service --state=running 2>/dev/null | grep -qE "lightdm|gdm3|sddm|lxdm"; then @@ -96,23 +109,52 @@ if [ -f /etc/os-release ]; then echo "✓ Lite version confirmed (no desktop environment)" fi else - echo "✗ ERROR: Could not detect OS version (/etc/os-release not found)" + echo "✗ ERROR: Could not detect OS version ($LM_OS_RELEASE_FILE not found)" OS_CHECK_FAILED=1 fi +# Python: whatever python3 the release ships (3.11 on Bookworm, 3.13 on +# Trixie). Checked only when python3 is already there -- Step 1 installs it +# otherwise, and on a supported release that brings the release's own version. +if [ "$OS_CHECK_FAILED" -eq 0 ]; then + if PYTHON3_VERSION=$(lm_python_version); then + case "$(lm_python_check "$PYTHON3_VERSION")" in + ok) + echo "✓ Python $PYTHON3_VERSION detected" + ;; + too-old) + echo "✗ ERROR: python3 is Python $PYTHON3_VERSION; LEDMatrix needs Python 3.$LM_PYTHON_MIN_MINOR or newer" + echo " $(lm_release_label "$OS_RELEASE") ships Python $(lm_release_python "$OS_RELEASE"). Something on this" + echo " system has changed which Python 'python3' runs; point it back at the system Python." + OS_CHECK_FAILED=1 + ;; + *) + echo "⚠ python3 is Python $PYTHON3_VERSION, which LEDMatrix has not been tested with" + echo " (tested: 3.$LM_PYTHON_MIN_MINOR to 3.$LM_PYTHON_MAX_MINOR). Continuing anyway." + ;; + esac + else + echo "python3 not found yet; Step 1 installs it." + fi +fi + if [ "$OS_CHECK_FAILED" -eq 1 ]; then echo "" - echo "Installation cannot continue. Please install Raspberry Pi OS Lite (Trixie) and try again." - echo "" - echo "To install Raspberry Pi OS Lite (Trixie):" - echo " 1. Download from: https://www.raspberrypi.com/software/operating-systems/" - echo " 2. Select 'Raspberry Pi OS Lite (64-bit)' with Debian 13 (Trixie)" - echo " 3. Flash to SD card using Raspberry Pi Imager" - echo " 4. Boot and run this script again" + echo "Installation cannot continue." + lm_print_supported_os_help exit 1 fi echo "✓ OS requirements met" + +# WiFi setup (the web page's WiFi tab and the LEDMatrix-Setup hotspot) needs +# NetworkManager. Both releases use it by default; say so plainly if this Pi +# does not, but carry on -- the display itself does not depend on it. +case "$(lm_network_stack)" in + networkmanager) echo "✓ NetworkManager is managing the network" ;; + dhcpcd) lm_print_dhcpcd_advice ;; + *) echo "⚠ Could not tell which service manages the network; WiFi setup from the web page needs NetworkManager" ;; +esac echo "" # The user who ran the installer: SUDO_USER once we are running under sudo @@ -1370,15 +1412,16 @@ if ! command -v setcap >/dev/null 2>&1; then echo "⚠ setcap not found, skipping capability configuration" echo " Install libcap2-bin if you need hardware timing capabilities" else - # Find the Python binary and resolve symlinks to get the real binary + # The binary the services run (ExecStart=/usr/bin/python3), symlinks + # resolved: python3.11 on Bookworm, python3.13 on Trixie. This used to + # prefer /usr/bin/python3.13 whenever it existed, which would set the + # capability on an interpreter the services never run if python3 pointed + # elsewhere. PYTHON_BIN="" PYTHON_VER="" - if [ -f "/usr/bin/python3.13" ]; then - PYTHON_BIN=$(readlink -f /usr/bin/python3.13) - PYTHON_VER="3.13" - elif [ -f "/usr/bin/python3" ]; then + if [ -f "/usr/bin/python3" ]; then PYTHON_BIN=$(readlink -f /usr/bin/python3) - PYTHON_VER=$(python3 --version 2>&1 | grep -oP '(?<=Python )\d+\.\d+' || echo "unknown") + PYTHON_VER=$(lm_python_version /usr/bin/python3) || PYTHON_VER="unknown" fi if [ -n "$PYTHON_BIN" ] && [ -f "$PYTHON_BIN" ]; then diff --git a/mypy.ini b/mypy.ini index 603521c6..929e8b85 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6,8 +6,9 @@ files = src exclude = (^|/)(test|__pycache__)/ -# Python version -python_version = 3.10 +# Python version: the oldest the installer supports (Raspberry Pi OS +# Bookworm ships 3.11; Trixie ships 3.13). +python_version = 3.11 # Platform (Linux/Raspberry Pi) platform = linux @@ -103,8 +104,8 @@ ignore_missing_imports = True # numpy's own stubs (numpy>=2.3) use Python 3.12 `type` statements, which mypy -# refuses to parse under python_version = 3.10 -- and 3.10 is the floor this -# code has to run on, so it stays. Treat numpy as Any instead: skip it, and +# refuses to parse under python_version = 3.11 -- and 3.11 (Bookworm) is the +# floor this code has to run on, so it stays. Treat numpy as Any instead: skip it, and # follow_imports_for_stubs makes the skip apply to its .pyi files too. [mypy-numpy.*] follow_imports = skip diff --git a/requirements.txt b/requirements.txt index f91b55d0..46641bad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # LEDMatrix Core Dependencies -# Compatible with Python 3.10, 3.11, 3.12, and 3.13 +# Compatible with Python 3.11, 3.12 and 3.13; CI tests 3.11 and 3.13 # Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie) # Image processing diff --git a/scripts/check_system_compatibility.sh b/scripts/check_system_compatibility.sh index 0a8530e6..4b800c94 100755 --- a/scripts/check_system_compatibility.sh +++ b/scripts/check_system_compatibility.sh @@ -53,26 +53,35 @@ else fi echo "" -# Check OS version +# Check OS version. The supported releases come from the same library the +# installer uses, so the two cannot disagree. echo "2. Checking Operating System Version..." echo "---------------------------------------" -if [ -f /etc/os-release ]; then - . /etc/os-release - echo "OS: $PRETTY_NAME" - echo "Version ID: ${VERSION_ID:-unknown}" - - # first_time_install.sh refuses anything but Raspberry Pi OS / Debian 13 - # (Trixie), so anything else is an error here too, not a warning. - if [[ "$ID" == "raspbian" ]] || [[ "$ID" == "debian" ]]; then - if [ "${VERSION_ID:-0}" = "13" ]; then - print_success "Detected Debian 13 Trixie - supported" - elif [ "${VERSION_ID:-0}" = "12" ]; then - print_error "Debian 12 Bookworm is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13" - else - print_error "Debian/Raspbian ${VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13" - fi +OS_LIB="$(cd "$(dirname "$0")" && pwd)/install/lib_os.sh" +OS_LIB_LOADED=0 +OS_RELEASE="" +if [ -f "$OS_LIB" ]; then + # shellcheck source=scripts/install/lib_os.sh + . "$OS_LIB" + OS_LIB_LOADED=1 +fi + +if [ "$OS_LIB_LOADED" = "0" ]; then + print_error "$OS_LIB is missing - download LEDMatrix again" +elif [ -r "$LM_OS_RELEASE_FILE" ]; then + OS_ID=$(lm_os_field ID) + OS_VERSION_ID=$(lm_os_field VERSION_ID) + echo "OS: $(lm_os_field PRETTY_NAME)" + echo "Version ID: ${OS_VERSION_ID:-unknown}" + + # first_time_install.sh refuses anything else, so this is an error here + # too, not a warning. + if OS_RELEASE=$(lm_os_release); then + print_success "Detected $(lm_release_label "$OS_RELEASE") - supported" + elif [[ "$OS_ID" == "raspbian" ]] || [[ "$OS_ID" == "debian" ]]; then + print_error "Debian/Raspbian ${OS_VERSION_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12)" else - print_error "${ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite (Trixie), Debian 13" + print_error "${OS_ID:-unknown} is not supported - the installer requires Raspberry Pi OS Lite, Trixie (Debian 13) or Bookworm (Debian 12)" fi else print_error "Could not detect OS version" @@ -92,7 +101,7 @@ if [ "$KERNEL_MAJOR" -ge "6" ]; then print_success "Kernel version is compatible (6.x or newer)" if [ "$KERNEL_MAJOR" -eq "6" ] && [ "$KERNEL_MINOR" -ge "12" ]; then - print_success "Running latest Trixie kernel (6.12 LTS)" + print_success "Running a 6.12 LTS or newer kernel" fi elif [ "$KERNEL_MAJOR" -eq "5" ] && [ "$KERNEL_MINOR" -ge "10" ]; then print_success "Kernel version is compatible (5.10+)" @@ -104,25 +113,34 @@ echo "" # Check Python version echo "4. Checking Python Version..." echo "-----------------------------" -if command -v python3 >/dev/null 2>&1; then - PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")') - PYTHON_MAJOR=$(python3 -c 'import sys; print(sys.version_info.major)') - PYTHON_MINOR=$(python3 -c 'import sys; print(sys.version_info.minor)') - +if [ "$OS_LIB_LOADED" = "1" ] && command -v python3 >/dev/null 2>&1; then + PYTHON_VERSION=$(python3 -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])') + PYTHON_MINOR_VERSION=$(lm_python_version) || PYTHON_MINOR_VERSION="" + PYTHON_RANGE="3.${LM_PYTHON_MIN_MINOR}-3.${LM_PYTHON_MAX_MINOR}" + echo "Python: $PYTHON_VERSION" - - if [ "$PYTHON_MAJOR" -eq "3" ]; then - if [ "$PYTHON_MINOR" -ge "10" ] && [ "$PYTHON_MINOR" -le "13" ]; then - print_success "Python version is supported (3.10-3.13)" - elif [ "$PYTHON_MINOR" -ge "14" ]; then - print_warning "Python 3.${PYTHON_MINOR} is very new - some packages may not be compatible yet" - else - # Pillow 12 and the pinned test tools need 3.10+, so this won't install. - print_error "Python 3.${PYTHON_MINOR} is too old - Python 3.10+ is required" - fi - else - print_error "Python 2.x detected - Python 3.10+ is required" + + case "$(lm_python_check "$PYTHON_MINOR_VERSION")" in + ok) + print_success "Python version is supported ($PYTHON_RANGE)" + ;; + too-old) + # The rgbmatrix bindings declare requires-python >=3.11, so the + # display cannot be built on anything older. + print_error "Python $PYTHON_MINOR_VERSION is too old - Python 3.${LM_PYTHON_MIN_MINOR}+ is required" + ;; + too-new) + print_warning "Python $PYTHON_MINOR_VERSION is newer than LEDMatrix has been tested with ($PYTHON_RANGE)" + ;; + *) + print_warning "Could not read the Python version" + ;; + esac + if [ -n "$OS_RELEASE" ] && [ "$PYTHON_MINOR_VERSION" != "$(lm_release_python "$OS_RELEASE")" ]; then + print_warning "$(lm_release_label "$OS_RELEASE") ships Python $(lm_release_python "$OS_RELEASE"), but python3 runs $PYTHON_MINOR_VERSION" fi +elif command -v python3 >/dev/null 2>&1; then + print_warning "Cannot check the Python version without $OS_LIB" else print_error "Python 3 not found - installation required" fi @@ -268,6 +286,22 @@ if command -v ping >/dev/null 2>&1; then else print_warning "Ping command not available - cannot verify network" fi + +# WiFi setup from the web page and the LEDMatrix-Setup hotspot drive +# NetworkManager, the default on both Bookworm and Trixie. +if [ "$OS_LIB_LOADED" = "1" ]; then + case "$(lm_network_stack)" in + networkmanager) + print_success "NetworkManager manages the network (needed for WiFi setup)" + ;; + dhcpcd) + print_warning "dhcpcd manages the network - WiFi setup from the web page and the setup hotspot need NetworkManager (sudo raspi-config -> Advanced Options -> Network Config)" + ;; + *) + print_warning "Could not tell which service manages the network - WiFi setup from the web page needs NetworkManager" + ;; + esac +fi echo "" # Print summary diff --git a/scripts/install/README.md b/scripts/install/README.md index 3679a030..9067a702 100644 --- a/scripts/install/README.md +++ b/scripts/install/README.md @@ -41,6 +41,9 @@ Libraries (sourced, not run): script that renders a unit from `systemd/*.service` - **`lib_lowmem.sh`** - Build-job sizing and temporary swap for the C++ build on low-memory Pis (`first_time_install.sh` Step 6) +- **`lib_os.sh`** - Which releases (Bookworm, Trixie) and Python versions + (3.11-3.13) the installer accepts, and which service runs the network; + shared by `first_time_install.sh` and `scripts/check_system_compatibility.sh` ## Usage diff --git a/scripts/install/lib_os.sh b/scripts/install/lib_os.sh new file mode 100644 index 00000000..236a1b83 --- /dev/null +++ b/scripts/install/lib_os.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Which operating systems and Python versions LEDMatrix installs on. +# +# Sourced by first_time_install.sh and scripts/check_system_compatibility.sh, +# so the installer and the compatibility checker cannot disagree about what +# is supported. Pure functions: nothing here installs, changes or exits -- +# the callers decide what to do with the answers. +# +# Supported (Lite, no desktop): +# Raspberry Pi OS / Debian 12 "Bookworm" -- Python 3.11 +# Raspberry Pi OS / Debian 13 "Trixie" -- Python 3.13 +# +# Everything the installer asks apt for (python3-pip, python3-venv, +# python-dev-is-python3, python3-pil, python3-pil.imagetk, build-essential, +# python3-setuptools, python3-wheel, cmake, ninja-build, git, curl, wget, +# unzip, and hostapd, dnsmasq, network-manager for WiFi setup) has the same +# name on both releases. Both ship a pip (23.0.1 and 25.1.1) that is PEP 668 +# "externally managed" and accepts --break-system-packages, and a cmake (3.25 +# and 3.31) new enough for the rgbmatrix build (3.22). So no step needs a +# per-release branch today; if one ever does, the release name comes from +# lm_os_release below. + +# Test hook: the os-release file to read. +LM_OS_RELEASE_FILE="${LM_OS_RELEASE_FILE:-/etc/os-release}" + +# Oldest and newest python3 minor versions the installer accepts. 3.11 is +# Bookworm's, and also the floor of the rgbmatrix bindings (requires-python +# >=3.11 in rpi-rgb-led-matrix-master/pyproject.toml); 3.13 is Trixie's. +LM_PYTHON_MIN_MINOR=11 +LM_PYTHON_MAX_MINOR=13 + +# lm_os_field KEY -- one value from os-release with its quotes removed; empty +# when the key or the file is missing. Parsed rather than sourced so that +# os-release's ID, VERSION and friends do not land in the caller's variables. +lm_os_field() { + [ -r "$LM_OS_RELEASE_FILE" ] || return 0 + sed -n "/^$1=/{s/^$1=//;s/^[\"']//;s/[\"']\$//;p;q;}" "$LM_OS_RELEASE_FILE" +} + +# lm_os_release -- print "bookworm" or "trixie" and succeed on a supported +# release; print nothing and fail on anything else. VERSION_ID decides; the +# codename is used only when VERSION_ID is missing. +lm_os_release() { + local id version + id=$(lm_os_field ID) + version=$(lm_os_field VERSION_ID) + [ -n "$version" ] || version=$(lm_os_field VERSION_CODENAME) + case "$id" in + raspbian|debian) ;; + *) return 1 ;; + esac + case "$version" in + 12|bookworm) echo bookworm ;; + 13|trixie) echo trixie ;; + *) return 1 ;; + esac +} + +# lm_release_label RELEASE -- how to name a release to a person. +lm_release_label() { + case "$1" in + bookworm) echo "Debian 12 (Bookworm)" ;; + trixie) echo "Debian 13 (Trixie)" ;; + *) echo "$1" ;; + esac +} + +# lm_release_python RELEASE -- the python3 version a release ships, e.g. 3.11. +lm_release_python() { + case "$1" in + bookworm) echo 3.11 ;; + trixie) echo 3.13 ;; + *) return 1 ;; + esac +} + +# lm_python_version [PYTHON] -- "3.11" and so on for python3 (or PYTHON); +# prints nothing and fails when it cannot be run. +lm_python_version() { + "${1:-python3}" -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null +} + +# lm_python_check VERSION -- print "ok", "too-old", "too-new" or "unknown" +# for a version such as 3.11. Always succeeds, so it is safe under set -e. +lm_python_check() { + local major minor + major=${1%%.*} + minor=${1#*.} + minor=${minor%%.*} + case "$major:$minor" in + *[!0-9:]*|:*|*:) echo unknown; return 0 ;; + esac + if [ "$major" -lt 3 ] || { [ "$major" -eq 3 ] && [ "$minor" -lt "$LM_PYTHON_MIN_MINOR" ]; }; then + echo too-old + elif [ "$major" -gt 3 ] || [ "$minor" -gt "$LM_PYTHON_MAX_MINOR" ]; then + echo too-new + else + echo ok + fi +} + +# lm_network_stack -- which service runs the network: "networkmanager", +# "dhcpcd" or "unknown". Raspberry Pi OS uses NetworkManager on both Bookworm +# and Trixie; dhcpcd appears when someone switched back to it in raspi-config. +lm_network_stack() { + if systemctl is-active --quiet NetworkManager 2>/dev/null; then + echo networkmanager + elif systemctl is-active --quiet dhcpcd 2>/dev/null; then + echo dhcpcd + else + echo unknown + fi +} + +# lm_print_dhcpcd_advice -- the explanation for a Pi running dhcpcd. WiFi +# setup from the web page and the LEDMatrix-Setup hotspot both drive +# NetworkManager (nmcli). The installer does not switch the network stack +# itself: doing that over SSH can cut the connection it is running on. +lm_print_dhcpcd_advice() { + echo "⚠ This Pi manages its network with dhcpcd, not NetworkManager." + echo " LEDMatrix installs and the display works, but choosing a WiFi network" + echo " from the web page and the LEDMatrix-Setup hotspot both need NetworkManager." + echo " To switch (with a keyboard and screen attached, or over Ethernet):" + echo " sudo raspi-config -> Advanced Options -> Network Config -> NetworkManager" + echo " then reboot." +} + +# lm_print_supported_os_help -- what to do on an unsupported system. +lm_print_supported_os_help() { + echo "LEDMatrix needs Raspberry Pi OS Lite: Trixie (Debian 13) or Bookworm (Debian 12)." + echo "" + echo "To install Raspberry Pi OS Lite:" + echo " 1. Download Raspberry Pi Imager from: https://www.raspberrypi.com/software/" + echo " 2. Choose 'Raspberry Pi OS Lite (64-bit)'. Trixie is the current version and" + echo " is recommended; Bookworm (listed as Legacy) also works" + echo " 3. Flash it to the SD card" + echo " 4. Boot the Pi and run this script again" +} diff --git a/scripts/install_dependencies_apt.py b/scripts/install_dependencies_apt.py index 44e6adc5..c579e445 100644 --- a/scripts/install_dependencies_apt.py +++ b/scripts/install_dependencies_apt.py @@ -4,13 +4,14 @@ Alternative dependency installer that tries apt packages first, then falls back to pip with --break-system-packages """ +import re import subprocess import sys import tempfile import warnings from collections import deque from pathlib import Path -from typing import List, Tuple +from typing import Dict, List, Tuple # How many trailing lines of a failed command's output to keep for the # end-of-run failure summary. Keeps the root cause near the end of the log, @@ -81,6 +82,8 @@ def install_via_pip(package_name: str) -> Tuple[bool, str]: Returns (success, output). """ + # pip knows PIL as Pillow; the others are asked for by their own name. + package_name = _dist_name(package_name) print(f"Installing {package_name} via pip...") success, output = _run([ sys.executable, '-m', 'pip', 'install', @@ -99,26 +102,66 @@ IMPORT_NAME_MAP = { 'freetype-py': 'freetype', } -# Minimum versions that must be met for an already-installed package to count -# as satisfied. Debian Bookworm's python3-freetype is 2.3.0, below the -# freetype-py>=2.5.1 pin in requirements.txt, so an import-only check would -# wrongly skip the pip upgrade. -MIN_VERSIONS = { - 'freetype-py': (2, 5, 1), +# The packages above are keyed by what main() lists; these are the ones whose +# pip distribution name differs from that key. +DIST_NAME_MAP = { + 'PIL': 'Pillow', } +REQUIREMENTS_FILE = Path(__file__).resolve().parent.parent / 'web_interface' / 'requirements.txt' + + +def _version_tuple(text: str) -> tuple: + parts = [] + for part in text.split('.'): + digits = ''.join(ch for ch in part if ch.isdigit()) + if not digits: + break + parts.append(int(digits)) + return tuple(parts) + + +def _requirement_floors(path: Path = REQUIREMENTS_FILE) -> Dict[str, tuple]: + """``>=`` floors from a requirements file, keyed by lower-cased name. + + The apt copies of these packages are older than the pins on both + supported releases -- Bookworm ships Flask and Werkzeug 2.2.2, Pillow 9.4, + requests 2.28, psutil 5.9, pytz 2022.7 and freetype-py 2.3; Trixie ships + Flask 3.1.1, Werkzeug 3.1.3, Pillow 11.1 and requests 2.32 -- + so a package that merely imports is not enough. Read from the file rather + than copied here so the two cannot drift. + """ + floors: Dict[str, tuple] = {} + try: + lines = path.read_text(encoding='utf-8').splitlines() + except OSError: + return floors + for line in lines: + match = re.match(r'\s*([A-Za-z0-9][A-Za-z0-9._-]*)[^#]*?>=\s*([0-9][0-9.]*)', line) + if match: + floors[match.group(1).lower()] = _version_tuple(match.group(2)) + return floors + + +def _dist_name(package_name: str) -> str: + return DIST_NAME_MAP.get(package_name, package_name) + + +def _minimum_version(package_name: str) -> tuple: + """The required floor for ``package_name``, or () when there is none.""" + return MIN_VERSIONS.get(_dist_name(package_name).lower(), ()) + + +# Minimum versions that must be met for an already-installed package to count +# as satisfied. +MIN_VERSIONS = _requirement_floors() + def _installed_version_tuple(dist_name: str) -> tuple: """Return the installed distribution version as an int tuple, or () if unknown.""" try: from importlib.metadata import version - parts = [] - for part in version(dist_name).split('.'): - digits = ''.join(ch for ch in part if ch.isdigit()) - if not digits: - break - parts.append(int(digits)) - return tuple(parts) + return _version_tuple(version(dist_name)) except Exception: return () @@ -134,9 +177,9 @@ def check_package_installed(package_name: str) -> bool: __import__(import_name) except ImportError: return False - minimum = MIN_VERSIONS.get(package_name) + minimum = _minimum_version(package_name) if minimum: - installed = _installed_version_tuple(package_name) + installed = _installed_version_tuple(_dist_name(package_name)) if not installed or installed < minimum: print(f"{package_name} is installed but below the required " f"{'.'.join(map(str, minimum))}; will upgrade via pip") @@ -188,10 +231,11 @@ def main(): continue # Try apt first, then pip. An apt install only counts if it also - # satisfies any minimum version (Debian's python3-freetype can be - # older than the freetype-py pin), otherwise fall through to pip. + # satisfies the requirements floor (the apt copies of most of these + # are older than the pins on both Bookworm and Trixie), otherwise + # fall through to pip. ok, apt_output = install_via_apt(package) - if ok and package in MIN_VERSIONS and not check_package_installed(package): + if ok and _minimum_version(package) and not check_package_installed(package): ok = False apt_output = f"apt version of {package} is below the required minimum" if not ok: diff --git a/test/test_install_os_support.py b/test/test_install_os_support.py new file mode 100644 index 00000000..95f1fe43 --- /dev/null +++ b/test/test_install_os_support.py @@ -0,0 +1,343 @@ +"""The installer accepts Raspberry Pi OS Bookworm and Trixie, and nothing else. + +Bookworm (Debian 12) ships Python 3.11 and Trixie (Debian 13) Python 3.13. +The rules live in scripts/install/lib_os.sh, which first_time_install.sh and +scripts/check_system_compatibility.sh both source. These tests feed the real +scripts a fake /etc/os-release (LM_OS_RELEASE_FILE) and stub python3, +systemctl and dpkg, so they need a Linux bash; the installer itself cannot +run end to end off a Pi. +""" + +import importlib.util +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +LIB = ROOT / "scripts" / "install" / "lib_os.sh" +FIRST_TIME = ROOT / "first_time_install.sh" +COMPAT = ROOT / "scripts" / "check_system_compatibility.sh" + +needs_bash = pytest.mark.skipif( + sys.platform == "win32" or shutil.which("bash") is None, + reason="runs the installer's shell code; needs a Linux bash", +) + +OS_RELEASES = { + # Raspberry Pi OS 64-bit reports ID=debian, 32-bit ID=raspbian. + "trixie": 'PRETTY_NAME="Debian GNU/Linux 13 (trixie)"\nNAME="Debian GNU/Linux"\n' + 'VERSION_ID="13"\nVERSION="13 (trixie)"\nVERSION_CODENAME=trixie\nID=debian\n', + "bookworm": 'PRETTY_NAME="Raspbian GNU/Linux 12 (bookworm)"\nNAME="Raspbian GNU/Linux"\n' + 'VERSION_ID="12"\nVERSION="12 (bookworm)"\nVERSION_CODENAME=bookworm\nID=raspbian\n' + 'ID_LIKE=debian\n', + "bookworm64": 'PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"\nVERSION_ID="12"\n' + "VERSION_CODENAME=bookworm\nID=debian\n", + "bullseye": 'PRETTY_NAME="Raspbian GNU/Linux 11 (bullseye)"\nVERSION_ID="11"\n' + "VERSION_CODENAME=bullseye\nID=raspbian\n", + "ubuntu": 'PRETTY_NAME="Ubuntu 24.04 LTS"\nVERSION_ID="24.04"\nVERSION_CODENAME=noble\n' + "ID=ubuntu\nID_LIKE=debian\n", + "no-version-id": "PRETTY_NAME='Debian GNU/Linux trixie'\nVERSION_CODENAME=trixie\nID=debian\n", +} + + +def _stub(bin_dir: Path, name: str, body: str) -> None: + path = bin_dir / name + path.write_text("#!/bin/sh\n" + body, encoding="utf-8", newline="\n") + path.chmod(0o755) + + +def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager") -> Path: + """python3 reports ``python_version`` (None: not installed); systemctl + reports ``network`` as the only active unit; dpkg lists no desktop.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + if python_version is None: + # Shadows any real python3 further down PATH. + _stub(bin_dir, "python3", "exit 127\n") + else: + _stub(bin_dir, "python3", f'case "$*" in *"%d.%d.%d"*) echo "{python_version}.1" ;; ' + f'*) echo "{python_version}" ;; esac\n') + _stub(bin_dir, "systemctl", + f'case "$*" in *"is-active --quiet {network}") exit 0 ;; esac\nexit 3\n') + _stub(bin_dir, "dpkg", "exit 0\n") + _stub(bin_dir, "dpkg-query", "exit 1\n") + _stub(bin_dir, "ping", "exit 0\n") + return bin_dir + + +def _env(tmp_path: Path, release: str, bin_dir: Path) -> dict: + os_release = tmp_path / "os-release" + os_release.write_text(OS_RELEASES[release], encoding="utf-8", newline="\n") + return { + "PATH": f"{bin_dir}:/usr/bin:/bin:/usr/sbin:/sbin", + "LM_OS_RELEASE_FILE": str(os_release), + "HOME": str(tmp_path), + } + + +def lib(snippet: str, env: dict) -> subprocess.CompletedProcess: + return subprocess.run(["bash", "-c", f"set -Eeuo pipefail\n. '{LIB}'\n{snippet}"], + capture_output=True, text=True, env=env) + + +# --- lib_os.sh ----------------------------------------------------------------- + +@needs_bash +class TestLibrary: + def test_library_is_syntactically_valid(self): + result = subprocess.run(["bash", "-n", str(LIB)], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + @pytest.mark.parametrize("release,expected", [ + ("trixie", "trixie"), + ("bookworm", "bookworm"), + ("bookworm64", "bookworm"), + ("no-version-id", "trixie"), + ]) + def test_supported_releases_are_recognised(self, tmp_path, release, expected): + result = lib("lm_os_release", _env(tmp_path, release, _stubs(tmp_path))) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == expected + + @pytest.mark.parametrize("release", ["bullseye", "ubuntu"]) + def test_other_systems_are_refused(self, tmp_path, release): + result = lib("lm_os_release", _env(tmp_path, release, _stubs(tmp_path))) + assert result.returncode != 0 + assert result.stdout.strip() == "" + + def test_missing_os_release_is_refused_not_fatal_to_the_caller(self, tmp_path): + env = _env(tmp_path, "trixie", _stubs(tmp_path)) + env["LM_OS_RELEASE_FILE"] = str(tmp_path / "absent") + result = lib('if lm_os_release; then echo yes; else echo no; fi', env) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "no" + + def test_quoted_fields_are_unquoted(self, tmp_path): + env = _env(tmp_path, "no-version-id", _stubs(tmp_path)) + result = lib("lm_os_field PRETTY_NAME; lm_os_field VERSION_ID", env) + assert result.stdout == "Debian GNU/Linux trixie\n" + + @pytest.mark.parametrize("release,python", [("bookworm", "3.11"), ("trixie", "3.13")]) + def test_each_release_names_the_python_it_ships(self, tmp_path, release, python): + result = lib(f"lm_release_python {release}", _env(tmp_path, "trixie", _stubs(tmp_path))) + assert result.stdout.strip() == python + + @pytest.mark.parametrize("version,verdict", [ + ("3.11", "ok"), ("3.12", "ok"), ("3.13", "ok"), + ("3.10", "too-old"), ("3.9", "too-old"), ("2.7", "too-old"), + ("3.14", "too-new"), ("4.0", "too-new"), + ("", "unknown"), ("garbage", "unknown"), + ]) + def test_python_versions(self, tmp_path, version, verdict): + result = lib(f'lm_python_check "{version}"', _env(tmp_path, "trixie", _stubs(tmp_path))) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == verdict + + @pytest.mark.parametrize("active,expected", [ + ("NetworkManager", "networkmanager"), + ("dhcpcd", "dhcpcd"), + ("nothing", "unknown"), + ]) + def test_network_stack(self, tmp_path, active, expected): + env = _env(tmp_path, "bookworm", _stubs(tmp_path, network=active)) + assert lib("lm_network_stack", env).stdout.strip() == expected + + +# --- first_time_install.sh's OS check ------------------------------------------ + +def _os_check_section() -> str: + """first_time_install.sh from the OS check up to the next section, with + the desktop-marker directories pointed somewhere that cannot exist.""" + text = FIRST_TIME.read_text(encoding="utf-8").replace("\r\n", "\n") + start = text.index("# Check OS version") + end = text.index("# The user who ran the installer") + section = text[start:end] + for marker in ("/usr/share/raspberrypi-ui-mods", "/usr/share/xsessions"): + assert marker in section + section = section.replace(marker, "/nonexistent" + marker) + return section + + +def run_os_check(tmp_path: Path, release: str, **stub_args) -> subprocess.CompletedProcess: + """Run the OS check as the installer would, from a copy of the project + layout so ``$(dirname "$0")/scripts/install/lib_os.sh`` resolves.""" + project = tmp_path / "project" + (project / "scripts" / "install").mkdir(parents=True) + shutil.copy(LIB, project / "scripts" / "install" / "lib_os.sh") + script = project / "first_time_install.sh" + script.write_text("set -Eeuo pipefail\n" + "trap 'echo ERR-TRAP line $LINENO >&2; exit 99' ERR\n" + + _os_check_section() + '\necho "SECTION-DONE"\n', + encoding="utf-8", newline="\n") + env = _env(tmp_path, release, _stubs(tmp_path, **stub_args)) + return subprocess.run(["bash", str(script)], capture_output=True, text=True, env=env) + + +@needs_bash +class TestInstallerOsCheck: + @pytest.mark.parametrize("release,python,label", [ + ("bookworm", "3.11", "Debian 12 (Bookworm)"), + ("bookworm64", "3.11", "Debian 12 (Bookworm)"), + ("trixie", "3.13", "Debian 13 (Trixie)"), + ]) + def test_supported_release_passes(self, tmp_path, release, python, label): + result = run_os_check(tmp_path, release, python_version=python) + assert result.returncode == 0, result.stdout + result.stderr + assert f"✓ {label} detected" in result.stdout + assert f"✓ Python {python} detected" in result.stdout + assert "✓ OS requirements met" in result.stdout + assert "SECTION-DONE" in result.stdout + + @pytest.mark.parametrize("release,reason", [ + ("bullseye", "This version of Raspberry Pi OS is not supported"), + ("ubuntu", "This script requires Raspberry Pi OS"), + ]) + def test_unsupported_system_stops_with_directions(self, tmp_path, release, reason): + result = run_os_check(tmp_path, release) + assert result.returncode == 1, result.stdout + result.stderr + assert reason in result.stdout + assert "Installation cannot continue." in result.stdout + assert "Trixie (Debian 13) or Bookworm (Debian 12)" in result.stdout + assert "SECTION-DONE" not in result.stdout + + def test_python_older_than_the_rgbmatrix_floor_stops(self, tmp_path): + result = run_os_check(tmp_path, "bookworm", python_version="3.10") + assert result.returncode == 1, result.stdout + result.stderr + assert "needs Python 3.11 or newer" in result.stdout + assert "ships Python 3.11" in result.stdout + + def test_untested_newer_python_warns_and_continues(self, tmp_path): + result = run_os_check(tmp_path, "trixie", python_version="3.14") + assert result.returncode == 0, result.stdout + result.stderr + assert "has not been tested with" in result.stdout + + def test_missing_python_is_left_to_step_1(self, tmp_path): + result = run_os_check(tmp_path, "trixie", python_version=None) + assert result.returncode == 0, result.stdout + result.stderr + assert "Step 1 installs it" in result.stdout + + def test_dhcpcd_is_explained_but_not_fatal(self, tmp_path): + result = run_os_check(tmp_path, "bookworm", network="dhcpcd") + assert result.returncode == 0, result.stdout + result.stderr + assert "dhcpcd, not NetworkManager" in result.stdout + assert "Network Config" in result.stdout + + def test_networkmanager_is_confirmed(self, tmp_path): + result = run_os_check(tmp_path, "trixie", python_version="3.13") + assert "✓ NetworkManager is managing the network" in result.stdout + + +# --- check_system_compatibility.sh --------------------------------------------- + +def run_compat(tmp_path: Path, release: str, **stub_args) -> subprocess.CompletedProcess: + env = _env(tmp_path, release, _stubs(tmp_path, **stub_args)) + return subprocess.run(["bash", str(COMPAT)], capture_output=True, text=True, env=env) + + +@needs_bash +class TestCompatibilityCheck: + @pytest.mark.parametrize("release,python,label", [ + ("bookworm", "3.11", "Debian 12 (Bookworm)"), + ("trixie", "3.13", "Debian 13 (Trixie)"), + ]) + def test_supported_release_is_reported_supported(self, tmp_path, release, python, label): + out = run_compat(tmp_path, release, python_version=python).stdout + assert f"Detected {label} - supported" in out + assert "Python version is supported (3.11-3.13)" in out + assert "not supported" not in out + assert "ships Python" not in out + + @pytest.mark.parametrize("release", ["bullseye", "ubuntu"]) + def test_unsupported_release_is_an_error(self, tmp_path, release): + result = run_compat(tmp_path, release) + assert result.returncode == 1 + assert "is not supported - the installer requires Raspberry Pi OS Lite" in result.stdout + + def test_python_below_the_floor_is_an_error(self, tmp_path): + result = run_compat(tmp_path, "bookworm", python_version="3.10") + assert result.returncode == 1 + assert "Python 3.10 is too old - Python 3.11+ is required" in result.stdout + + def test_dhcpcd_is_a_warning(self, tmp_path): + out = run_compat(tmp_path, "bookworm", network="dhcpcd").stdout + assert "dhcpcd manages the network" in out + assert "NetworkManager manages the network" not in out + + def test_python_that_is_not_the_releases_own_is_flagged(self, tmp_path): + out = run_compat(tmp_path, "trixie", python_version="3.11").stdout + assert "Debian 13 (Trixie) ships Python 3.13, but python3 runs 3.11" in out + + +# --- things that must hold for both Python versions ---------------------------- + +def test_installer_scripts_do_not_hard_code_a_python_minor_version(): + """The services run /usr/bin/python3, which is 3.11 on Bookworm and 3.13 + on Trixie; naming either one in an installer or a unit breaks the other.""" + paths = [FIRST_TIME, *sorted((ROOT / "scripts" / "install").glob("*.sh")), + *sorted((ROOT / "systemd").glob("*.service"))] + offenders = [] + for path in paths: + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if line.lstrip().startswith("#"): + continue + if re.search(r"python3\.1[0-9]", line): + offenders.append(f"{path.relative_to(ROOT)}:{n}: {line.strip()}") + assert not offenders, "\n".join(offenders) + + +def _load_apt_installer(): + spec = importlib.util.spec_from_file_location( + "install_dependencies_apt", ROOT / "scripts" / "install_dependencies_apt.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestAptFallbackRespectsThePins: + """Step 7's apt-first fallback must not accept the releases' older apt + copies (Bookworm: Flask 2.2.2, Pillow 9.4; Trixie: Flask 3.1.1).""" + + def test_floors_come_from_the_web_requirements(self): + mod = _load_apt_installer() + for name in ("flask", "werkzeug", "pillow", "requests", "psutil", "pytz", "freetype-py"): + assert mod.MIN_VERSIONS.get(name), f"no floor read for {name}" + assert mod.MIN_VERSIONS["freetype-py"] == (2, 5, 1) + + @pytest.mark.parametrize("package,apt_version", [ + ("flask", (2, 2, 2)), # Bookworm + ("flask", (3, 1, 1)), # Trixie + ("PIL", (9, 4, 0)), # Bookworm python3-pil + ("werkzeug", (2, 2, 2)), + ("freetype-py", (2, 3, 0)), + ]) + def test_an_apt_copy_below_the_pin_does_not_count(self, monkeypatch, package, apt_version): + mod = _load_apt_installer() + monkeypatch.setattr(mod, "_installed_version_tuple", lambda dist: apt_version) + monkeypatch.setitem(sys.modules, mod.IMPORT_NAME_MAP.get(package, package), object()) + assert mod.check_package_installed(package) is False + + def test_a_version_at_the_pin_counts(self, monkeypatch): + mod = _load_apt_installer() + floor = mod.MIN_VERSIONS["flask"] + monkeypatch.setattr(mod, "_installed_version_tuple", lambda dist: floor) + monkeypatch.setitem(sys.modules, "flask", object()) + assert mod.check_package_installed("flask") is True + + def test_the_pillow_version_is_looked_up_under_its_distribution_name(self, monkeypatch): + mod = _load_apt_installer() + seen = [] + monkeypatch.setattr(mod, "_installed_version_tuple", lambda dist: seen.append(dist) or (99,)) + monkeypatch.setitem(sys.modules, "PIL", object()) + assert mod.check_package_installed("PIL") is True + assert seen == ["Pillow"] + + def test_pip_is_asked_for_pillow_not_pil(self, monkeypatch): + mod = _load_apt_installer() + calls = [] + monkeypatch.setattr(mod, "_run", lambda cmd: calls.append(cmd) or (True, "")) + assert mod.install_via_pip("PIL") == (True, "") + assert calls[0][-1] == "Pillow" diff --git a/test/test_install_reboot_is_last.py b/test/test_install_reboot_is_last.py index 3ae62832..d18aa669 100644 --- a/test/test_install_reboot_is_last.py +++ b/test/test_install_reboot_is_last.py @@ -6,8 +6,8 @@ Complete / Web UI Access" summary. `reboot` returns at once and the script carried on printing while the system went down, so the SSH session usually dropped before the user saw the web UI address. -first_time_install.sh exits on anything but Raspberry Pi OS Trixie before it -parses its arguments, so the behavioural test runs only the tail of the +first_time_install.sh exits on anything but Raspberry Pi OS Bookworm or Trixie +before it parses its arguments, so the behavioural test runs only the tail of the script -- from the summary to the end -- with systemctl, nmcli, hostname, ip and reboot stubbed. """ diff --git a/web_interface/requirements.txt b/web_interface/requirements.txt index 01f24373..ea140dc7 100644 --- a/web_interface/requirements.txt +++ b/web_interface/requirements.txt @@ -1,5 +1,5 @@ # LEDMatrix Web Interface Dependencies -# Compatible with Python 3.10, 3.11, 3.12, and 3.13 +# Compatible with Python 3.11, 3.12 and 3.13; CI tests 3.11 and 3.13 # Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie) # Web framework