mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
<img width="512" height="361" alt="Step 2 Other " src="https://github.com/user-attachments/assets/166a22e8-8067-48df-9f80-50c91f573356" />
|
||||
|
||||
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
|
||||
|
||||
<img width="512" height="361" alt="Step 4 Trixie Lite 64" src="https://github.com/user-attachments/assets/3b8590ce-b810-4dfe-9253-26e0d4f8ed1e" />
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+79
-36
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user