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
-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
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