Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ python3 --version
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.
- "A desktop is running": use the Lite image, not the desktop one, or boot
to the console with `sudo systemctl set-default multi-user.target` and
reboot. Desktop packages that are installed but not running only produce a
warning, and the install continues.
- "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).
Expand Down
47 changes: 31 additions & 16 deletions first_time_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,29 +86,44 @@ if [ -r "$LM_OS_RELEASE_FILE" ]; then
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
# Check for a desktop. A desktop only competes with the panel for CPU while
# it runs, so a running display manager stops the install; desktop packages
# or session files on a Pi that boots to the console are only a warning.
DESKTOP_RUNNING=0
DESKTOP_INSTALLED=0
# display-manager is the alias every Debian display manager registers.
for dm in display-manager lightdm gdm gdm3 sddm lxdm; do
if systemctl is-active --quiet "$dm" 2>/dev/null; then
DESKTOP_RUNNING=1
fi
done
# grep without -q: -q exits at the first match, dpkg then dies of SIGPIPE,
# and pipefail turns a found desktop into "not found".
# Match installed package names from their start: the unanchored ".*kde"
# matched mid-word (libblockdev-* = "bloc-kde-v") on Lite, and `dpkg -l`
# lines also carry descriptions that could match.
# Desktop metapackages and session managers, matched as whole installed
# package names: an unanchored ".*kde" matched libblockdev-* ("bloc-kde-v"),
# and a "gnome" prefix matched standalone parts such as gnome-keyring.
# Trixie replaced raspberrypi-ui-mods with the rpd-*-core metapackages.
DESKTOP_PACKAGES='raspberrypi-ui-mods|rpd-wayland-core|rpd-x-core'
DESKTOP_PACKAGES+='|lxde|lxde-core|lxsession|xfce4|xfce4-session'
DESKTOP_PACKAGES+='|gnome-shell|gnome-session|kde-plasma-desktop|plasma-desktop'
DESKTOP_PACKAGES+='|plasma-workspace|task-desktop|task-[a-z0-9]+-desktop'
if dpkg-query -W -f='${db:Status-Abbrev} ${binary:Package}\n' 2>/dev/null \
| grep -E "^ii +(raspberrypi-ui-mods|lxde|xfce|gnome|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
DESKTOP_DETECTED=1
| grep -E "^ii +(${DESKTOP_PACKAGES})(:[a-z0-9]+)?$" >/dev/null; then
DESKTOP_INSTALLED=1
fi
if [ -d /usr/share/raspberrypi-ui-mods ] || [ -d /usr/share/xsessions ]; then
DESKTOP_DETECTED=1
DESKTOP_INSTALLED=1
fi

if [ "$DESKTOP_DETECTED" -eq 1 ]; then
echo "✗ ERROR: Desktop environment detected - this script requires Raspberry Pi OS Lite"
echo " Please use Raspberry Pi OS Lite (not the full desktop version)"

if [ "$DESKTOP_RUNNING" -eq 1 ]; then
echo "✗ ERROR: A desktop is running - this script requires Raspberry Pi OS Lite"
echo " Please use Raspberry Pi OS Lite (not the full desktop version), or boot"
echo " to the console: sudo systemctl set-default multi-user.target && sudo reboot"
OS_CHECK_FAILED=1
elif [ "$DESKTOP_INSTALLED" -eq 1 ]; then
echo "⚠ WARNING: Desktop packages are installed, but no desktop is running."
echo " Continuing. Keep the Pi booting to the console: a running desktop"
echo " competes with the LED panel for CPU and can make it flicker."
else
echo "✓ Lite version confirmed (no desktop environment)"
fi
Expand Down
13 changes: 13 additions & 0 deletions src/common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Rules for the package:
| [`api_helper`](#api_helper) | HTTP GET/POST with caching and rate limiting | Yes | — |
| [`bdf_font`](#bdf_font) | Load and draw BDF bitmap fonts | Yes, if drawing BDF text directly | 3.5.0 |
| [`espn_dates`](#espn_dates) | Fetch ESPN scoreboards across a date range | Yes (scoreboards) | 3.5.0 |
| [`espn_payload`](#espn_payload) | Drop the parts of an ESPN scoreboard payload no scoreboard reads | No, core-internal (used by `BackgroundDataService`) | n/a |
| [`favorite_team_check`](#favorite_team_check) | Log why a favourite team code shows nothing | Yes (scoreboards) | 3.6.0 |
| [`fetch_service`](#fetch_service) | Pooled, merged, budgeted and counted HTTP for core fetch paths | No, core-internal (reached through `api_helper` and `espn_dates`) | n/a |
| [`font_layout`](#font_layout) | Reproducible TrueType loading, crisp sizes | Yes | 3.4.0 |
Expand Down Expand Up @@ -120,6 +121,18 @@ Every request goes through [`fetch_service`](#fetch_service), the chunks
counted against the plugin that asked. Scoreboard plugins also bundle a copy
for older cores.

### espn_payload

[`espn_payload.py`](espn_payload.py). Core-internal. ESPN scoreboard
responses carry stat leaders, athlete cards, links, headlines and highlights
that no scoreboard draws. `slim_scoreboard_payload(payload)` removes exactly
those keys, in place, and leaves everything it does not know about alone;
`is_espn_scoreboard_url(url)` says whether a URL is an ESPN site-API
scoreboard. `BackgroundDataService` slims each scoreboard window before
caching it, which cuts the five sports windows from ~40MB to ~12MB of parsed
objects. Adding a key to the drop lists means first checking that nothing
reads it.

### favorite_team_check

[`favorite_team_check.py`](favorite_team_check.py).
Expand Down
69 changes: 59 additions & 10 deletions test/test_install_os_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ def _stub(bin_dir: Path, name: str, body: str) -> None:
path.chmod(0o755)


def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager") -> Path:
def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager",
active=(), packages=()) -> Path:
"""python3 reports ``python_version`` (None: not installed); systemctl
reports ``network`` as the only active unit; dpkg lists no desktop."""
reports ``network`` and ``active`` as the only active units; dpkg-query
lists ``packages`` as installed (none by default, so no desktop)."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir(exist_ok=True)
if python_version is None:
Expand All @@ -61,10 +63,14 @@ def _stubs(tmp_path: Path, python_version="3.11", network="NetworkManager") -> P
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')
units = "|".join(f'*"is-active --quiet {unit}"' for unit in (network, *active))
_stub(bin_dir, "systemctl", f'case "$*" in {units}) exit 0 ;; esac\nexit 3\n')
_stub(bin_dir, "dpkg", "exit 0\n")
_stub(bin_dir, "dpkg-query", "exit 1\n")
if packages:
listing = "".join(f"ii {name}\\n" for name in packages)
_stub(bin_dir, "dpkg-query", f'printf "{listing}"\n')
else:
_stub(bin_dir, "dpkg-query", "exit 1\n")
_stub(bin_dir, "ping", "exit 0\n")
return bin_dir

Expand Down Expand Up @@ -149,20 +155,22 @@ def test_network_stack(self, tmp_path, active, expected):

# --- first_time_install.sh's OS check ------------------------------------------

def _os_check_section() -> str:
def _os_check_section(marker_root: str = "/nonexistent") -> str:
"""first_time_install.sh from the OS check up to the next section, with
the desktop-marker directories pointed somewhere that cannot exist."""
the desktop-marker directories moved under ``marker_root`` (by default
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)
section = section.replace(marker, marker_root + marker)
return section


def run_os_check(tmp_path: Path, release: str, **stub_args) -> subprocess.CompletedProcess:
def run_os_check(tmp_path: Path, release: str, marker_root: str = "/nonexistent",
**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"
Expand All @@ -171,7 +179,7 @@ def run_os_check(tmp_path: Path, release: str, **stub_args) -> subprocess.Comple
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',
+ _os_check_section(marker_root) + '\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)
Expand Down Expand Up @@ -230,6 +238,47 @@ 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

# A running desktop stops the install; one that is only installed warns.

@pytest.mark.parametrize("unit", ["display-manager", "lightdm", "gdm", "sddm"])
def test_running_desktop_stops(self, tmp_path, unit):
result = run_os_check(tmp_path, "trixie", python_version="3.13", active=(unit,))
assert result.returncode == 1, result.stdout + result.stderr
assert "A desktop is running" in result.stdout
assert "multi-user.target" in result.stdout
assert "SECTION-DONE" not in result.stdout

@pytest.mark.parametrize("package", [
"raspberrypi-ui-mods", "rpd-wayland-core", "rpd-x-core", "xfce4",
"lxde-core", "gnome-shell", "kde-plasma-desktop", "plasma-workspace:arm64",
"task-desktop", "task-mate-desktop",
])
def test_installed_desktop_that_is_not_running_warns(self, tmp_path, package):
result = run_os_check(tmp_path, "trixie", python_version="3.13",
packages=("bash", package))
assert result.returncode == 0, result.stdout + result.stderr
assert "Desktop packages are installed, but no desktop is running" in result.stdout
assert "✓ OS requirements met" in result.stdout

def test_desktop_session_files_warn(self, tmp_path):
(tmp_path / "markers" / "usr" / "share" / "xsessions").mkdir(parents=True)
result = run_os_check(tmp_path, "trixie", python_version="3.13",
marker_root=str(tmp_path / "markers"))
assert result.returncode == 0, result.stdout + result.stderr
assert "Desktop packages are installed, but no desktop is running" in result.stdout

@pytest.mark.parametrize("packages", [
# libblockdev contains "kde" mid-word; the old check stopped on it.
("libblockdev-crypto3", "libblockdev3:arm64"),
("gnome-keyring", "xfce4-terminal", "xfconf", "lxde-icon-theme",
"kde-cli-tools", "gnome-session-common", "task-ssh-server", "rpd-plym-splash"),
])
def test_lite_with_desktop_named_parts_is_lite(self, tmp_path, packages):
result = run_os_check(tmp_path, "trixie", python_version="3.13", packages=packages)
assert result.returncode == 0, result.stdout + result.stderr
assert "✓ Lite version confirmed" in result.stdout
assert "WARNING: Desktop" not in result.stdout


# --- check_system_compatibility.sh ---------------------------------------------

Expand Down
Loading