From fde64db866860a355278bcb025a1fedc347b9f4b Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:21:19 -0500 Subject: [PATCH 01/36] Add Python quality tooling configuration --- pyproject.toml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..547aea8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "glados" +version = "0.1.0" +description = "GLaDOS-inspired physical voice assistant head" +requires-python = ">=3.13" +dependencies = [ + "Pillow>=11,<13", + "numpy>=2,<3", + "websockets>=15,<16", +] + +[dependency-groups] +dev = [ + "ruff>=0.13,<0.14", + "ty>=0.0.1,<0.1", +] + +[tool.ruff] +target-version = "py313" +line-length = 100 +extend-exclude = [ + "display/waveshare", +] + +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F", "I", "UP"] + +[tool.ruff.format] +quote-style = "double" + +[tool.ty.environment] +python-version = "3.13" +root = ["."] + +[tool.ty.src] +include = ["display"] + +[build-system] +requires = ["setuptools>=75"] +build-backend = "setuptools.build_meta" From 15e122d8f8c9fde4017f2b5db067a417978fd84d Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:21:22 -0500 Subject: [PATCH 02/36] Add Python quality CI --- .github/workflows/python-quality.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/python-quality.yml diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml new file mode 100644 index 0000000..a3b65f7 --- /dev/null +++ b/.github/workflows/python-quality.yml @@ -0,0 +1,34 @@ +name: Python quality + +on: + push: + branches: [main, "ci/**"] + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.13" + + - name: Install project and quality tools + run: uv sync --dev + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format + run: uv run ruff format --check . + + - name: ty + run: uv run ty check + + - name: Compile Python + run: uv run python -m compileall -q display From c8b60e906a25862e1dca519f3f7e44bc17daa032 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:21:47 -0500 Subject: [PATCH 03/36] Fix display renderer lint issues --- display/glados_display.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/display/glados_display.py b/display/glados_display.py index d070fd9..8153af2 100644 --- a/display/glados_display.py +++ b/display/glados_display.py @@ -6,7 +6,6 @@ import os import time from pathlib import Path -from typing import Optional from PIL import Image import websockets @@ -53,7 +52,7 @@ class GladosDisplay: """Render the GLaDOS aperture and follow LVA voice-assistant events.""" - def __init__(self, driver: Optional[DisplayDriver] = None): + def __init__(self, driver: DisplayDriver | None = None): self.driver = driver or DisplayDriver() self.frames = self._load_frames() self.state = DisplayState.IDLE @@ -124,7 +123,7 @@ def _frame_at(self, now: float) -> tuple[int, float]: next_frame_time = self.state_started + (frame + 1) * FRAME_DURATION return sequence[frame], next_frame_time - def render(self, now: Optional[float] = None) -> None: + def render(self, now: float | None = None) -> None: now = now if now is not None else time.monotonic() if self.state == DisplayState.ERROR: self._render_error(now) @@ -173,7 +172,7 @@ async def _event_loop_heartbeat(self) -> None: ) expected = now - def _render_task_done(self, task) -> None: + def _render_task_done(self, task: asyncio.Task[None]) -> None: if task.cancelled(): _LOGGER.warning("Render task was cancelled") elif task.exception() is not None: @@ -185,10 +184,8 @@ async def _render_loop(self) -> None: """Render whenever the animation frame is due.""" try: while True: - loop_started = time.monotonic() - now = loop_started + now = time.monotonic() self.render(now) - render_finished = time.monotonic() if self.state == DisplayState.ERROR: delay = 0.05 @@ -270,10 +267,15 @@ def demo() -> None: display.set_state(DisplayState.IDLE) display.render() time.sleep(1.5) - for state in (DisplayState.WAKE, DisplayState.LISTENING, DisplayState.THINKING, DisplayState.SPEAKING): + for state in ( + DisplayState.WAKE, + DisplayState.LISTENING, + DisplayState.THINKING, + DisplayState.SPEAKING, + ): display.set_state(state) start = time.monotonic() - duration = len(SEQUENCES[state]) / FPS + duration = len(SEQUENCES[state]) * FRAME_DURATION while time.monotonic() - start < duration: display.render() time.sleep(FRAME_DURATION) From 08ef5ea99ae6c69717cb205846fdbc0af08d7119 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:21:50 -0500 Subject: [PATCH 04/36] Fix display driver lint issues --- display/driver.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/display/driver.py b/display/driver.py index 5117e20..9c8a362 100644 --- a/display/driver.py +++ b/display/driver.py @@ -1,10 +1,12 @@ """Hardware adapter for the Waveshare 1.28-inch GC9A01 display.""" -from pathlib import Path import logging +import os +from pathlib import Path import sys import time -import os + +from PIL import Image WAVESHARE_DRIVER_DIR = Path("/opt/glados/display/waveshare") _LOGGER = logging.getLogger("glados.display.driver") @@ -12,15 +14,18 @@ class DisplayDriver: """Small hardware-neutral wrapper around Waveshare's GC9A01 display.""" + WIDTH = 240 HEIGHT = 240 - def __init__(self): + def __init__(self) -> None: sys.path.insert(0, str(WAVESHARE_DRIVER_DIR)) try: from lib import LCD_1inch28 except ImportError as exc: - raise RuntimeError("Waveshare driver not installed. Run scripts/install-display.sh first.") from exc + raise RuntimeError( + "Waveshare driver not installed. Run scripts/install-display.sh first." + ) from exc # Keep the full 240x240 update window for reliable GC9A01 refreshes. # Use 12-bit MCU pixel mode to reduce each framebuffer by 25%. @@ -36,14 +41,18 @@ def __init__(self): self._lcd.data(0x03) self._lcd.clear() - def _rgb444(self, image): + def _rgb444(self, image: Image.Image) -> bytes: """Pack two RGB444 pixels into three bytes for GC9A01 12-bit mode.""" img = self._lcd.np.asarray(image) r = self._lcd.np.right_shift(img[..., 0], 4) g = self._lcd.np.right_shift(img[..., 1], 4) b = self._lcd.np.right_shift(img[..., 2], 4) - pixels = (r.astype(self._lcd.np.uint16) << 8) | (g.astype(self._lcd.np.uint16) << 4) | b + pixels = ( + r.astype(self._lcd.np.uint16) << 8 + ) | ( + g.astype(self._lcd.np.uint16) << 4 + ) | b pixels = pixels.reshape(-1) if pixels.size % 2: @@ -60,7 +69,7 @@ def _rgb444(self, image): packed[:, 2] = self._lcd.np.bitwise_and(p1, 0xFF) return packed.tobytes() - def show(self, image): + def show(self, image: Image.Image) -> None: if image.size != (self.WIDTH, self.HEIGHT): raise ValueError("Display image must be exactly 240x240 pixels") @@ -119,9 +128,9 @@ def show(self, image): y0, ) - def clear(self): + def clear(self) -> None: self._lcd.clear() - def close(self): + def close(self) -> None: self._lcd.clear() self._lcd.bl_DutyCycle(0) From faa342b3f52dbbb7e6a223573bcefc74ec425fda Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:22:35 -0500 Subject: [PATCH 05/36] Run CI tools without packaging the hardware project --- pyproject.toml | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 547aea8..e813184 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,3 @@ -[project] -name = "glados" -version = "0.1.0" -description = "GLaDOS-inspired physical voice assistant head" -requires-python = ">=3.13" -dependencies = [ - "Pillow>=11,<13", - "numpy>=2,<3", - "websockets>=15,<16", -] - -[dependency-groups] -dev = [ - "ruff>=0.13,<0.14", - "ty>=0.0.1,<0.1", -] - [tool.ruff] target-version = "py313" line-length = 100 @@ -34,7 +17,3 @@ root = ["."] [tool.ty.src] include = ["display"] - -[build-system] -requires = ["setuptools>=75"] -build-backend = "setuptools.build_meta" From 7248dc96356e67067f681d68be6560208dcf461f Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:22:41 -0500 Subject: [PATCH 06/36] Run quality tools directly with uv --- .github/workflows/python-quality.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index a3b65f7..c327cfe 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -18,17 +18,14 @@ jobs: with: python-version: "3.13" - - name: Install project and quality tools - run: uv sync --dev - - name: Ruff lint - run: uv run ruff check . + run: uv run --with ruff --with pillow --with numpy --with websockets ruff check . - name: Ruff format - run: uv run ruff format --check . + run: uv run --with ruff ruff format --check . - name: ty - run: uv run ty check + run: uv run --with ty --with pillow --with numpy --with websockets ty check - name: Compile Python run: uv run python -m compileall -q display From d8b983154219ac2876a1738e3b0c83b21fc885d9 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:23:24 -0500 Subject: [PATCH 07/36] Use StrEnum for display state --- display/states.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/display/states.py b/display/states.py index 2625bb1..f2d272b 100644 --- a/display/states.py +++ b/display/states.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class DisplayState(str, Enum): +class DisplayState(StrEnum): IDLE = "idle" WAKE = "wake" LISTENING = "listening" From 36d8aa19a67001f836de00f53101148a194ffe84 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:23:29 -0500 Subject: [PATCH 08/36] Fix display import ordering --- display/glados_display.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/display/glados_display.py b/display/glados_display.py index 8153af2..45c89d6 100644 --- a/display/glados_display.py +++ b/display/glados_display.py @@ -7,8 +7,8 @@ import time from pathlib import Path -from PIL import Image import websockets +from PIL import Image try: from .driver import DisplayDriver From 58b806dfeb313fc655b6ba4c20cc1f8a6bf7fb03 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:23:36 -0500 Subject: [PATCH 09/36] Fix display driver import ordering --- display/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/display/driver.py b/display/driver.py index 9c8a362..80f94e1 100644 --- a/display/driver.py +++ b/display/driver.py @@ -2,9 +2,9 @@ import logging import os -from pathlib import Path import sys import time +from pathlib import Path from PIL import Image From c9ef01ca1f0cfed942374b9d6b013c610b613302 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:24:56 -0500 Subject: [PATCH 10/36] Apply Ruff formatting to display driver --- display/driver.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/display/driver.py b/display/driver.py index 80f94e1..c0d6d96 100644 --- a/display/driver.py +++ b/display/driver.py @@ -48,11 +48,7 @@ def _rgb444(self, image: Image.Image) -> bytes: g = self._lcd.np.right_shift(img[..., 1], 4) b = self._lcd.np.right_shift(img[..., 2], 4) - pixels = ( - r.astype(self._lcd.np.uint16) << 8 - ) | ( - g.astype(self._lcd.np.uint16) << 4 - ) | b + pixels = (r.astype(self._lcd.np.uint16) << 8) | (g.astype(self._lcd.np.uint16) << 4) | b pixels = pixels.reshape(-1) if pixels.size % 2: From 01ba099499a49bca3d1462442ec21a0ded4c6ce4 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:25:14 -0500 Subject: [PATCH 11/36] Apply Ruff formatting to display smoke test --- display/test_display.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/display/test_display.py b/display/test_display.py index 1868ba1..b5fa27f 100644 --- a/display/test_display.py +++ b/display/test_display.py @@ -1,4 +1,6 @@ """Physical display smoke test.""" + + from glados_display import demo if __name__ == "__main__": From a5989ec005df1e2ccac2b3acc47e38764cfd6190 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:25:49 -0500 Subject: [PATCH 12/36] Fix smoke test formatting --- display/test_display.py | 1 - 1 file changed, 1 deletion(-) diff --git a/display/test_display.py b/display/test_display.py index b5fa27f..de9b90a 100644 --- a/display/test_display.py +++ b/display/test_display.py @@ -1,6 +1,5 @@ """Physical display smoke test.""" - from glados_display import demo if __name__ == "__main__": From 9d3323f4dd5807e2aa7a4a559104da3a18b58ee6 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:26:46 -0500 Subject: [PATCH 13/36] Configure uv without packaging the hardware project --- pyproject.toml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e813184..11bbfca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,23 @@ +[project] +name = "glados" +version = "0.1.0" +description = "GLaDOS-inspired physical voice assistant head" +requires-python = ">=3.13" +dependencies = [ + "Pillow>=11,<13", + "numpy>=2,<3", + "websockets>=15,<16", +] + +[dependency-groups] +dev = [ + "ruff>=0.13,<0.14", + "ty>=0.0.1,<0.1", +] + +[tool.uv] +package = false + [tool.ruff] target-version = "py313" line-length = 100 From 901d6d7f7ad2ffd02796ebf937bd666cd460ea3d Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:26:53 -0500 Subject: [PATCH 14/36] Install CI dependencies in the uv environment --- .github/workflows/python-quality.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index c327cfe..4e3bc09 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -18,14 +18,17 @@ jobs: with: python-version: "3.13" + - name: Install Python dependencies + run: uv sync --dev + - name: Ruff lint - run: uv run --with ruff --with pillow --with numpy --with websockets ruff check . + run: uv run ruff check . - name: Ruff format - run: uv run --with ruff ruff format --check . + run: uv run ruff format --check . - name: ty - run: uv run --with ty --with pillow --with numpy --with websockets ty check + run: uv run ty check - name: Compile Python run: uv run python -m compileall -q display From 8f0153202d0d827df13f78928f98b0f79cb088b8 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:26:55 -0500 Subject: [PATCH 15/36] Use package import in display smoke test --- display/test_display.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/display/test_display.py b/display/test_display.py index de9b90a..d300e51 100644 --- a/display/test_display.py +++ b/display/test_display.py @@ -1,6 +1,6 @@ """Physical display smoke test.""" -from glados_display import demo +from display.glados_display import demo if __name__ == "__main__": demo() From 0f4a3c290366323618eee45e98f67b89db0b9a07 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:27:35 -0500 Subject: [PATCH 16/36] Use package-relative display imports --- display/glados_display.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/display/glados_display.py b/display/glados_display.py index 45c89d6..ba494c7 100644 --- a/display/glados_display.py +++ b/display/glados_display.py @@ -10,12 +10,8 @@ import websockets from PIL import Image -try: - from .driver import DisplayDriver - from .states import DisplayState -except ImportError: - from driver import DisplayDriver - from states import DisplayState +from .driver import DisplayDriver +from .states import DisplayState _LOGGER = logging.getLogger("glados.display") From fbd2c99dba180a2697a6600aa7c048df69990bf2 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:27:40 -0500 Subject: [PATCH 17/36] Ignore external Waveshare import in ty --- display/driver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/display/driver.py b/display/driver.py index c0d6d96..ef221f7 100644 --- a/display/driver.py +++ b/display/driver.py @@ -21,7 +21,7 @@ class DisplayDriver: def __init__(self) -> None: sys.path.insert(0, str(WAVESHARE_DRIVER_DIR)) try: - from lib import LCD_1inch28 + from lib import LCD_1inch28 # ty: ignore[unresolved-import] except ImportError as exc: raise RuntimeError( "Waveshare driver not installed. Run scripts/install-display.sh first." From d7b38d3277eeacf0cf959cb0d0c9010aadf541f3 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:28:23 -0500 Subject: [PATCH 18/36] Use current Node 24 GitHub Actions --- .github/workflows/python-quality.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index 4e3bc09..cab2f3f 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -12,9 +12,9 @@ jobs: quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@v10 with: python-version: "3.13" From bc810cded142016bfb9d6cd58dead7c0960fb2b9 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:29:01 -0500 Subject: [PATCH 19/36] Pin current GitHub Actions versions --- .github/workflows/python-quality.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index cab2f3f..7efccf0 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -12,9 +12,9 @@ jobs: quality: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: astral-sh/setup-uv@v10 + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: python-version: "3.13" From 2ea3cb1e3a11f3acdc4c56ca38fd796e1c38fddf Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:31:14 -0500 Subject: [PATCH 20/36] Add pytest coverage tooling --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 11bbfca..d888cdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ dev = [ "ruff>=0.13,<0.14", "ty>=0.0.1,<0.1", + "pytest>=8,<9", + "pytest-cov>=6,<8", ] [tool.uv] From 4497782ef84eb2335ad142defaacf47681fa4af0 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:31:21 -0500 Subject: [PATCH 21/36] Run pytest with 95 percent coverage --- .github/workflows/python-quality.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index 7efccf0..9ebd929 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -30,5 +30,8 @@ jobs: - name: ty run: uv run ty check + - name: Tests and coverage + run: uv run pytest --cov=display --cov-report=term-missing --cov-fail-under=95 + - name: Compile Python run: uv run python -m compileall -q display From 65914a714c0f67b7cb536d40c3efdb02a298fd90 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:31:23 -0500 Subject: [PATCH 22/36] Remove hard-coded install user --- scripts/install-display.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/install-display.sh b/scripts/install-display.sh index dc182a8..f4fef58 100755 --- a/scripts/install-display.sh +++ b/scripts/install-display.sh @@ -2,7 +2,8 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -INSTALL_DIR="/opt/glados/display" +INSTALL_ROOT="${INSTALL_ROOT:-/opt/glados}" +INSTALL_DIR="${INSTALL_ROOT}/display" WAVESHARE_DIR="$INSTALL_DIR/waveshare" ZIP_URL="https://files.waveshare.com/upload/8/8d/LCD_Module_RPI_code.zip" TMP_DIR="$(mktemp -d)" @@ -26,7 +27,8 @@ sudo rm -rf "$WAVESHARE_DIR/lib" sudo cp -a "$SOURCE_DIR/lib" "$WAVESHARE_DIR/" sudo mkdir -p "$INSTALL_DIR" sudo cp -a "$REPO_ROOT/display/." "$INSTALL_DIR/" -sudo chown -R administrator:administrator "$INSTALL_DIR" +INSTALL_USER="${GLADOS_USER:-${SUDO_USER:-$(id -un)}}" +sudo chown -R "$INSTALL_USER:$INSTALL_USER" "$INSTALL_ROOT" echo echo "Display software installed under $INSTALL_DIR" From 27d9dd8c2add2f1addeaf4a9af315ac2a9684405 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:31:27 -0500 Subject: [PATCH 23/36] Use the configured install user --- scripts/install-services.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install-services.sh b/scripts/install-services.sh index d427dee..b363ed1 100755 --- a/scripts/install-services.sh +++ b/scripts/install-services.sh @@ -34,7 +34,7 @@ sudo rm -f /etc/systemd/system/glados-boot-sounds.service sudo install -m 0644 "${REPO_ROOT}/services/glados-powerup.service" /etc/systemd/system/glados-powerup.service sudo install -m 0644 "${REPO_ROOT}/services/glados-wakeup.service" /etc/systemd/system/glados-wakeup.service -sudo install -m 0644 "${REPO_ROOT}/services/glados-display.service" /etc/systemd/system/glados-display.service +sed "s/@GLADOS_USER@/${INSTALL_USER}/g" "${REPO_ROOT}/services/glados-display.service" | sudo tee /etc/systemd/system/glados-display.service >/dev/null sudo systemctl daemon-reload sudo systemctl enable glados-powerup.service From 3418ad5974b5c0aeb84516483f9510a593fea9d7 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:31:29 -0500 Subject: [PATCH 24/36] Parameterize display service user --- services/glados-display.service | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/glados-display.service b/services/glados-display.service index 6b90b14..ec60411 100644 --- a/services/glados-display.service +++ b/services/glados-display.service @@ -5,8 +5,8 @@ Wants=network-online.target [Service] Type=simple -User=administrator -Group=administrator +User=@GLADOS_USER@ +Group=@GLADOS_USER@ WorkingDirectory=/opt/glados Environment=PYTHONUNBUFFERED=1 Environment=LVA_WS_URL=ws://127.0.0.1:6055 From 6cdac38ec62847b2a7b4408e657ffca533d310ed Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:31:54 -0500 Subject: [PATCH 25/36] Add display unit tests with coverage --- tests/test_display.py | 340 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 tests/test_display.py diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..c2728a3 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,340 @@ +"""Unit tests for the GLaDOS display renderer and hardware adapter.""" + +import asyncio +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import numpy as np +import pytest +from PIL import Image + +from display import driver as driver_module +from display.glados_display import ( + ACTIVE_STATES, + FRAME_DURATION, + GladosDisplay, + SEQUENCES, +) +from display.states import DisplayState + + +class FakeDisplayDriver: + def __init__(self): + self.shown = [] + self.clears = 0 + self.closed = False + + def show(self, image): + self.shown.append(image) + + def clear(self): + self.clears += 1 + + def close(self): + self.closed = True + + +@pytest.fixture +def display(): + return GladosDisplay(FakeDisplayDriver()) + + +def test_loads_all_frames(display): + assert len(display.frames) == 9 + assert all(frame.size == (240, 240) for frame in display.frames) + assert display.frames[0].mode == "RGB" + + +def test_state_changes_reset_animation(display, monkeypatch): + monkeypatch.setattr("display.glados_display.time.monotonic", lambda: 10.0) + display.set_state(DisplayState.SPEAKING) + assert display.state == DisplayState.SPEAKING + assert display.state_started == 10.0 + assert display._last_frame_key is None + + display.set_state(DisplayState.SPEAKING) + assert display.state_started == 10.0 + + +@pytest.mark.parametrize( + ("state", "elapsed", "expected"), + [ + (DisplayState.IDLE, 0.0, 0), + (DisplayState.WAKE, FRAME_DURATION * 8.9, 8), + (DisplayState.LISTENING, FRAME_DURATION * 1.1, 7), + (DisplayState.THINKING, FRAME_DURATION * 2.1, 4), + (DisplayState.SPEAKING, FRAME_DURATION * 9.1, 7), + ], +) +def test_frame_at(display, state, elapsed): + display.state = state + display.state_started = 100.0 + frame, next_due = display._frame_at(100.0 + elapsed) + assert frame == expected + assert next_due > 100.0 + elapsed + + +def test_show_frame_deduplicates(display): + display._show_frame(0) + display._show_frame(0) + display._show_frame(1) + assert len(display.driver.shown) == 2 + + +@pytest.mark.parametrize( + ("event", "expected"), + [(event, state) for event, state in ACTIVE_STATES.items()], +) +def test_active_events(event, expected, display): + display.handle_event(event, {}) + assert display.state == expected + assert display.connection_lost is False + + +def test_snapshot_connection_states(display): + display.handle_event("snapshot", {"ha_connected": False}) + assert display.state == DisplayState.ERROR + assert display.connection_lost is True + + display.handle_event("snapshot", {"ha_connected": True}) + assert display.state == DisplayState.IDLE + assert display.connection_lost is False + + +def test_misc_events(display): + display.handle_event("disconnected", {}) + assert display.state == DisplayState.ERROR + assert display.connection_lost is True + + display.handle_event("zeroconf", {"status": "connected"}) + assert display.state == DisplayState.IDLE + assert display.connection_lost is False + + display.handle_event("pipeline_error", {}) + assert display.state == DisplayState.ERROR + assert display.connection_lost is False + + previous = display.state + display.handle_event("unknown", {}) + assert display.state == previous + + +def test_render_and_error_paths(display, monkeypatch): + monkeypatch.setattr("display.glados_display.time.monotonic", lambda: 10.0) + display.set_state(DisplayState.IDLE) + display.render(10.0) + assert len(display.driver.shown) == 1 + + display.connection_lost = True + display.set_state(DisplayState.ERROR) + display.render(display.state_started) + display.render(display.state_started + 0.5) + assert display.driver.clears == 1 + + display.connection_lost = False + display.state_started = 20.0 + display.render(20.1) + display.render(20.3) + assert display.driver.clears == 2 + + display.state_started = 18.0 + display.render(20.0) + assert display.state == DisplayState.IDLE + + +@pytest.mark.asyncio +async def test_heartbeat_warns_on_lag(display, monkeypatch, caplog): + sleeps = iter([None, asyncio.CancelledError()]) + expected = iter([10.0, 10.5]) + monkeypatch.setattr("display.glados_display.time.monotonic", lambda: next(expected)) + + async def fake_sleep(_): + value = next(sleeps) + if isinstance(value, BaseException): + raise value + + monkeypatch.setattr("display.glados_display.asyncio.sleep", fake_sleep) + with pytest.raises(asyncio.CancelledError): + await display._event_loop_heartbeat() + assert "EVENT LOOP STALL" in caplog.text + + +def test_render_task_done_logs(display, caplog): + cancelled = MagicMock() + cancelled.cancelled.return_value = True + display._render_task_done(cancelled) + assert "cancelled" in caplog.text + + failed = MagicMock() + failed.cancelled.return_value = False + failed.exception.return_value = RuntimeError("boom") + display._render_task_done(failed) + assert "CRASHED" in caplog.text + + finished = MagicMock() + finished.cancelled.return_value = False + finished.exception.return_value = None + display._render_task_done(finished) + assert "exited unexpectedly" in caplog.text + + +@pytest.mark.asyncio +async def test_render_loop_can_be_cancelled(display): + async def stop_sleep(_): + raise asyncio.CancelledError + + display.render = MagicMock() + original_sleep = asyncio.sleep + try: + asyncio.sleep = stop_sleep + with pytest.raises(asyncio.CancelledError): + await display._render_loop() + finally: + asyncio.sleep = original_sleep + display.render.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_handles_events_and_cancellation(display, monkeypatch): + class FakeWebSocket: + def __init__(self): + self.messages = iter( + [ + json.dumps({"event": "snapshot", "data": {"ha_connected": True}}), + "{invalid", + json.dumps({"event": "listening", "data": {}}), + ] + ) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.messages) + except StopIteration: + raise StopAsyncIteration + + class FakeConnection: + def __init__(self): + self.ws = FakeWebSocket() + + async def __aenter__(self): + return self.ws + + async def __aexit__(self, exc_type, exc, tb): + return False + + connections = iter([FakeConnection(), asyncio.CancelledError()]) + + def connect(*_args, **_kwargs): + next_connection = next(connections) + if isinstance(next_connection, BaseException): + raise next_connection + return next_connection + + async def idle_task(): + await asyncio.Event().wait() + + monkeypatch.setattr("display.glados_display.websockets.connect", connect) + monkeypatch.setattr(display, "_render_loop", idle_task) + monkeypatch.setattr(display, "_event_loop_heartbeat", idle_task) + + with pytest.raises(asyncio.CancelledError): + await display.run() + + assert display.state == DisplayState.LISTENING + assert display.driver.closed is True + + +@pytest.mark.asyncio +async def test_main(monkeypatch): + instance = MagicMock() + instance.run = AsyncMock() + monkeypatch.setattr("display.glados_display.GladosDisplay", MagicMock(return_value=instance)) + from display.glados_display import main + + await main() + instance.run.assert_awaited_once() + + +def test_driver_rgb444(): + driver = object.__new__(driver_module.DisplayDriver) + driver._lcd = SimpleNamespace(np=np) + image = Image.new("RGB", (2, 1), (255, 128, 16)) + assert driver._rgb444(image) == bytes([0xFF, 0xF8, 0x10]) + + +def test_driver_rgb444_odd_pixels(): + driver = object.__new__(driver_module.DisplayDriver) + driver._lcd = SimpleNamespace(np=np) + image = Image.new("RGB", (1, 1), (255, 0, 0)) + assert len(driver._rgb444(image)) == 3 + + +def test_driver_show_full_frame(): + driver = object.__new__(driver_module.DisplayDriver) + driver._lcd = MagicMock() + driver._lcd.np = np + driver._lcd.DC_PIN = 25 + driver._lcd.SPI.writebytes2 = MagicMock() + driver.WIDTH = 240 + driver.HEIGHT = 240 + driver._frame_buffers = {} + image = Image.new("RGB", (240, 240), (1, 2, 3)) + driver.show(image) + driver._lcd.SetWindows.assert_called_once_with(0, 0, 240, 240) + driver._lcd.SPI.writebytes2.assert_called_once() + assert driver._last_image is image + + +def test_driver_show_changed_region_and_cache(): + driver = object.__new__(driver_module.DisplayDriver) + driver._lcd = MagicMock() + driver._lcd.np = np + driver._lcd.DC_PIN = 25 + driver._lcd.SPI.writebytes2 = MagicMock() + driver.WIDTH = 240 + driver.HEIGHT = 240 + driver._frame_buffers = {} + + first = Image.new("RGB", (240, 240), (0, 0, 0)) + second = first.copy() + second.putpixel((20, 30), (255, 214, 0)) + driver.show(first) + driver.show(second) + calls = driver._lcd.SPI.writebytes2.call_count + driver.show(second) + assert driver._lcd.SPI.writebytes2.call_count == calls + 1 + + third = second.copy() + third.putpixel((21, 30), (255, 214, 0)) + driver.show(third) + assert driver._lcd.SetWindows.call_args.args == (21, 30, 22, 31) + + +def test_driver_show_rejects_wrong_size(): + driver = object.__new__(driver_module.DisplayDriver) + driver._frame_buffers = {} + with pytest.raises(ValueError, match="240x240"): + driver.show(Image.new("RGB", (10, 10))) + + +def test_driver_show_identical_image_is_noop(): + driver = object.__new__(driver_module.DisplayDriver) + driver._lcd = MagicMock() + driver._lcd.np = np + driver.WIDTH = 240 + driver.HEIGHT = 240 + driver._frame_buffers = {} + image = Image.new("RGB", (240, 240)) + driver._last_image = image + driver.show(image) + driver._lcd.SPI.writebytes2.assert_not_called() + + +def test_close_handles_driver_error(display, caplog): + display.driver.close = MagicMock(side_effect=RuntimeError("boom")) + display.close() + assert "Error while closing display" in caplog.text From 5006a31d2e1d0bacb10160870cd8d58a09c68a4a Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:32:02 -0500 Subject: [PATCH 26/36] Replace smoke test with pytest suite --- display/test_display.py | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 display/test_display.py diff --git a/display/test_display.py b/display/test_display.py deleted file mode 100644 index d300e51..0000000 --- a/display/test_display.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Physical display smoke test.""" - -from display.glados_display import demo - -if __name__ == "__main__": - demo() From b77736b375f4ca6208d8e70b126b429e62ffd828 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:32:34 -0500 Subject: [PATCH 27/36] Rewrite README for portable setup documentation --- README.md | 533 ++++++++++++++++++++---------------------------------- 1 file changed, 198 insertions(+), 335 deletions(-) diff --git a/README.md b/README.md index f90c99e..42372be 100644 --- a/README.md +++ b/README.md @@ -1,452 +1,315 @@ # GLaDOS -A physical GLaDOS-inspired voice assistant head built around a Raspberry Pi 4, Home Assistant, and local voice-assistant infrastructure. +A physical GLaDOS-inspired voice assistant head built around a Raspberry Pi, Home Assistant, Linux Voice Assistant (LVA), and a small SPI display. ## Overview -The GLaDOS head is the physical endpoint for a Home Assistant voice assistant. The Raspberry Pi handles the local hardware and audio endpoint while Home Assistant provides the voice-assistant pipeline. - -Current architecture: - -```text -USB Microphone - │ - ▼ -Linux Voice Assistant (LVA) - ├─ Local MicroWakeWord - └─ Peripheral WebSocket API - │ - ├──────────────► GLaDOS Display - │ - │ ESPHome API - ▼ -Home Assistant - └─ NVIDIA-backed Assist pipeline - │ - ▼ -USB Speaker -``` - -The Raspberry Pi is intentionally **not** responsible for local STT, LLM inference, or TTS. Those workloads remain on the Home Assistant infrastructure. +This repository contains the software that runs on the physical GLaDOS endpoint: -## Hardware - -### Core - -| Component | Status | Reference | -|---|---|---| -| Raspberry Pi 4 Model B | ✅ Installed | [Amazon](https://www.amazon.com/s?k=Raspberry+Pi+4+Model+B) | -| MicroSD storage | ✅ Installed | — | -| USB-C power supply | ✅ Installed | — | +- A display renderer for the Waveshare 1.28-inch GC9A01 LCD +- Integration with LVA's peripheral WebSocket API +- Boot and startup sound services +- Installation scripts for the Pi +- Display artwork and supporting assets -### Audio - -| Component | Status | Reference | -|---|---|---| -| INNOTRIK USB Conference Microphone Speakerphone | ✅ Working | [Amazon](https://www.amazon.com/INNOTRIK-Conference-Microphone-Omnidirectional-Speakerphone/dp/B098DKS637) | +The Raspberry Pi acts as the local hardware endpoint. Home Assistant and the voice-assistant services provide the higher-level voice pipeline. -### Display +### Architecture -| Component | Status | Reference | -|---|---|---| -| Waveshare 1.28-inch LCD Module — SPI / GC9A01 / 240×240 IPS | ✅ Wired/tested | [Amazon](https://www.amazon.com/s?k=Waveshare+1.28inch+LCD+Module+GC9A01) | +~~~text +USB audio + │ + ▼ +Linux Voice Assistant + ├── wake-word detection + ├── ESPHome API ───────────────► Home Assistant + └── peripheral WebSocket API + │ + ▼ + GLaDOS display +~~~ -### Motion +The display does not connect directly to Home Assistant. It listens to LVA events over the peripheral WebSocket API. -| Component | Status | Reference | -|---|---|---| -| Waveshare Servo Driver HAT | 🔧 Integration pending | [Waveshare](https://www.waveshare.com/product/robotics/drivers-sensors/servo-driver-hat.htm) | -| DSSERVO DS3225 25 kg digital servo | 🔧 Pending | [Amazon](https://www.amazon.com/dp/B07RNFQYD2) | -| DSSERVO DS3235 35 kg digital servo | 🔧 Pending | [Amazon](https://www.amazon.com/ZOSKAY-Coreless-Digital-Stainless-arduino/dp/B07S9XZYN2) | +## Hardware -### Lighting +### Reference hardware -| Component | Status | Reference | -|---|---|---| -| 16× 5050 addressable RGB LED ring | 🔧 Integration pending | [Amazon](https://www.amazon.com/s?k=16+LED+5050+RGB+NeoPixel+ring) | +The current build uses: -The build uses a 16-LED ring with 5050 addressable RGB LEDs. +- Raspberry Pi 4 Model B +- Waveshare 1.28-inch 240×240 GC9A01 LCD +- USB microphone/speaker +- Raspberry Pi SPI interface -### Mechanical +The repository also contains planned integration for servos and an addressable LED ring, but those components are not required to run the current display and audio software. -- GLaDOS head/body components based on **Mr. Volt's** design -- 3D-printed components -- Additional mechanical hardware as required by the final assembly +### Display wiring -### Current Hardware Status +For the current Waveshare GC9A01 module: -| Component | Status | +| Display | Raspberry Pi | |---|---| -| Raspberry Pi 4 | ✅ Installed | -| USB audio | ✅ Working | -| Linux Voice Assistant | ✅ Working | -| Hey GLaDOS wake word | ✅ Working | -| Home Assistant Assist | ✅ Working | -| GC9A01 display | ✅ Wired/tested | -| GLaDOS display software | ✅ Working; LVA animations and optimized GC9A01 rendering | -| Servo Driver HAT | 🔧 Integration pending | -| Servos | 🔧 Integration pending | -| NeoPixel ring | 🔧 Integration pending | - +| VCC | 3.3V — pin 1 | +| GND | GND — pin 6 | +| DIN | GPIO10 / MOSI — pin 19 | +| CLK | GPIO11 / SCLK — pin 23 | +| CS | GPIO8 / CE0 — pin 24 | +| DC/DS | GPIO25 — pin 22 | +| RST | GPIO27 — pin 13 | +| BL | GPIO18 — pin 12 | +SPI must be enabled in the Raspberry Pi OS configuration. -## Linux Voice Assistant (LVA) Setup +The display implementation uses the official Waveshare Python driver as a hardware dependency. The driver is downloaded by the display installation script rather than committed to this repository. -The Raspberry Pi runs [Linux Voice Assistant](https://github.com/OHF-Voice/linux-voice-assistant) in Docker. LVA provides: +## Prerequisites -- Local MicroWakeWord detection -- The ESPHome API used by Home Assistant -- The peripheral WebSocket API used by the GLaDOS display -- USB microphone/speaker access through the host PipeWire/PulseAudio socket +Before installing this repository, provide the following: -The GLaDOS display connects directly to LVA on **TCP/WebSocket port 6055**. Home Assistant does not need a separate API token for the display. +### Raspberry Pi -### Prerequisites +- Raspberry Pi with a supported 64-bit Debian-based OS +- Python 3.13 or newer +- Git +- Working network access +- SPI enabled +- A user account that will own and run the GLaDOS services -The Pi should have: +### Audio -- Debian Linux on ARM64 -- Docker Engine -- Docker Compose v2 -- PipeWire and `pipewire-pulse` -- The USB microphone/speaker connected and working -- A persistent user runtime directory for PipeWire +- A USB microphone/speaker or other supported audio device +- PipeWire with the PulseAudio compatibility layer (pipewire-pulse) +- The audio device working from the host before starting LVA -Enable the user runtime to survive boot: +Verify the host can see the audio device with: ~~~bash -loginctl enable-linger administrator +pactl info ~~~ -Verify PipeWire/PulseAudio: +If pactl is not available, install/configure the host's PipeWire/PulseAudio tools before continuing. -~~~bash -pactl info -~~~ +### LVA -The default sink and source should point to the USB audio device. +Install and configure [Linux Voice Assistant](https://github.com/OHF-Voice/linux-voice-assistant) separately. -### Install LVA +LVA must: -Clone the upstream LVA project: +1. Be running on the same host. +2. Have access to the host audio device. +3. Expose its peripheral WebSocket API. +4. Provide the wake-word/audio functionality required by the overall voice-assistant setup. -~~~bash -cd ~ -git clone https://github.com/OHF-Voice/linux-voice-assistant.git -cd ~/linux-voice-assistant -cp .env.example .env +The GLaDOS display defaults to: + +~~~text +ws://127.0.0.1:6055 ~~~ -For the GLaDOS Pi, the important Docker environment is: +Set "LVA_WS_URL" if LVA is running somewhere else. -~~~dotenv -LVA_USER_ID="1000" -LVA_USER_GROUP="1000" -LVA_PULSE_SERVER="/run/user/${LVA_USER_ID}/pulse/native" -LVA_XDG_RUNTIME_DIR="/run/user/${LVA_USER_ID}" -LVA_PULSE_COOKIE="/run/user/${LVA_USER_ID}/pulse/cookie" -~~~ +### Home Assistant -The upstream Docker Compose configuration uses host networking and provides the ESPHome API on port **6053** and the peripheral WebSocket API on port **6055** by default. +Home Assistant is not required by the display process itself, but it is part of the intended voice-assistant architecture. Configure the LVA ESPHome device and Home Assistant Assist pipeline according to the LVA and Home Assistant documentation. -Start LVA: +## Installation + +Clone the repository: ~~~bash -docker compose up -d +git clone https://github.com/Jonah-May-OSS/GLaDOS.git +cd GLaDOS ~~~ -Check it: +Run the installer as the user that should own the installation: ~~~bash -docker compose ps -docker logs -f linux-voice-assistant +./scripts/install.sh ~~~ -Home Assistant should discover the LVA device through the ESPHome integration. Complete the Home Assistant Assist pipeline configuration before testing the physical GLaDOS head. +The installer: -### Hey GLaDOS wake word +1. Installs the display's OS-level Python dependencies. +2. Downloads the Waveshare GC9A01 driver. +3. Installs the GLaDOS files. +4. Installs the startup sound files. +5. Installs and enables the systemd services. +6. Starts the display service. -This build uses the custom **Hey GLaDOS** MicroWakeWord model from [TaterTotterson/microWakeWords](https://github.com/TaterTotterson/microWakeWords). +The default installation directory is: -The model configuration is: - -~~~yaml -micro_wake_word: - id: mww - models: - - model: https://github.com/TaterTotterson/microWakeWords/raw/refs/heads/main/microWakeWords/hey_glados.json - id: hey_glados -~~~ - -The current GLaDOS setup uses: - -~~~dotenv -WAKE_WORD_DIR="app/wakewords/custom" -WAKE_MODEL="hey_glados" +~~~text +/opt/glados ~~~ -The custom wake-word files are stored in the LVA Docker volume rather than committed to this repository. Do **not** put generated model files or machine-specific Docker volume contents in Git. - -After changing the wake-word files or LVA configuration: +Override it with "INSTALL_ROOT": ~~~bash -cd ~/linux-voice-assistant -docker compose restart linux-voice-assistant +INSTALL_ROOT=/some/path ./scripts/install.sh ~~~ -### LVA peripheral API - -LVA is the WebSocket server and hardware peripherals connect as clients: +If the installer is run through sudo, the invoking user is used for the service account. To select the account explicitly: -~~~text -GLaDOS display - │ - │ WebSocket - ▼ -ws://127.0.0.1:6055 - │ - ▼ -Linux Voice Assistant - │ - │ ESPHome API - ▼ -Home Assistant +~~~bash +GLADOS_USER=myuser ./scripts/install.sh ~~~ -The display listens for: - -| LVA event | Display state | -|---|---| -| `wake_word_detected` | Aperture opening | -| `listening` | Listening animation | -| `thinking` | Thinking animation | -| `tts_speaking` | Speaking animation | -| `tts_finished` / `idle` | Idle | -| `pipeline_error` | Error flash | -| `disconnected` | Connection-lost pulse | - -LVA sends a state snapshot immediately after the display connects, and the display automatically reconnects if LVA restarts. - -For LVA's complete peripheral API documentation, see the [upstream peripheral API documentation](https://github.com/OHF-Voice/linux-voice-assistant/blob/main/docs/peripheral_api.md). +The display service runs as that user rather than requiring a particular username. -### Docker startup +### Verify the installation -LVA should be configured with: +Check the display service: ~~~bash -docker compose up -d +sudo systemctl status glados-display.service ~~~ -and configured to restart automatically by the upstream Compose configuration. +Follow its logs: -The GLaDOS display is managed separately by systemd, so Docker and systemd can restart independently: - -~~~text -Boot - ├─ PipeWire - ├─ Docker - │ └─ Linux Voice Assistant - │ └─ peripheral API :6055 - │ - └─ glados-display.service - └─ connects/reconnects to LVA :6055 +~~~bash +sudo journalctl -u glados-display.service -f ~~~ -## Voice Assistant - -The Pi runs [Linux Voice Assistant](https://github.com/OHF-Voice/linux-voice-assistant) in Docker. - -The current wake word is: +Check the startup sound services: -**Hey GLaDOS** - -The custom MicroWakeWord model is based on the model published by Tater Totterson: - -https://github.com/TaterTotterson/microWakeWords - -The LVA container uses the `latest` image tag and is automatically updated daily by Watchtower. Watchtower is configured to update only the LVA container and clean up replaced images. - -## Audio - -PipeWire provides the audio session on the Raspberry Pi. - -The USB sound device is used for both: - -- Microphone input -- Speaker output - -The system uses the user's PipeWire/PulseAudio runtime socket so LVA can access the same audio devices. - -## Boot Sounds - -The head uses **two systemd services intentionally** so the startup sounds happen at two different points in the boot process: - -1. **Early power-up:** `glados-powerup.service` waits only for the USB audio device to appear, then plays `powerup01.wav`. -2. **Boot complete:** `glados-wakeup.service` runs after `multi-user.target`, then plays `powerup02.wav` followed by `glados_wakeup.wav`. - -This gives the intended sequence: - -```text -Pi powers on - │ - └─ glados-powerup.service - └─ powerup01.wav - │ - │ ...system continues booting... - │ - └─ glados-wakeup.service - ├─ powerup02.wav - └─ glados_wakeup.wav -``` - -The installation scripts place the runtime files under: +~~~bash +sudo systemctl status glados-powerup.service +sudo systemctl status glados-wakeup.service +~~~ -```text -/opt/glados/ -``` +## Configuration -The display service connects to the Linux Voice Assistant peripheral WebSocket API on port 6055. This lets the display follow Home Assistant Assist state without a Home Assistant access token or a separate HA API connection. +The display process supports these environment variables: -### Install +| Variable | Default | Purpose | +|---|---|---| +| "LVA_WS_URL" | "ws://127.0.0.1:6055" | LVA peripheral WebSocket endpoint | +| "LVA_RECONNECT_DELAY" | "3" | Seconds between LVA reconnect attempts | +| "GLADOS_DISPLAY_FRAME_DURATION" | "0.25" | Animation frame duration | +| "GLADOS_SPI_FREQ" | "62500000" | SPI frequency in Hz | +| "LOG_LEVEL" | "INFO" | Python logging level | -From a clone of this repository: +The systemd service currently supplies the LVA endpoint. Change the service environment if a non-default endpoint is required. -``` -cd ~/GLaDOS -git pull -./scripts/install.sh -``` - -The installation scripts are tracked as executable files in Git, so a normal checkout should not require `chmod +x`. +## Display -> **Raspberry Pi note:** Git can report executable-bit changes as local modifications when `core.filemode` is enabled and the checkout predates the executable-bit fix. On a dedicated Pi checkout, disable file-mode tracking once: -> -> ``` -> git config core.filemode false -> ``` -> -> This is a Git working-tree setting, not a `.gitignore` rule; `.gitignore` cannot ignore Unix permission changes. +The display renderer listens for LVA peripheral events and maps them to display states: -The installer installs the display dependencies and Waveshare driver, copies the display software to `/opt/glados`, downloads the required sound files, installs/enables the two boot sound services and display service, and starts the display service immediately. +| LVA event | Display state | +|---|---| +| "wake_word_detected" | Wake | +| "listening" | Listening | +| "thinking" | Thinking | +| "tts_speaking" | Speaking | +| "tts_finished" | Idle | +| "idle" | Idle | +| "pipeline_error" | Error | +| "disconnected" | Connection error state | -To test the early sound manually: +The renderer automatically reconnects to LVA after a connection failure. -``` -sudo systemctl start glados-powerup.service -``` +The GC9A01 is driven through a 240×240 window using 12-bit RGB444 transfers. The renderer calculates changed regions between display frames and only sends those regions over SPI when possible. -To test the late boot sequence manually: +## Startup sounds -``` -sudo systemctl start glados-wakeup.service -``` +Two systemd services handle startup audio: -Check the service logs: +- "glados-powerup.service" plays the initial power-up sound once the audio device is available. +- "glados-wakeup.service" plays the remaining startup sequence after the system reaches the normal multi-user boot target. -``` -sudo journalctl -u glados-powerup.service -n 50 --no-pager -sudo journalctl -u glados-wakeup.service -n 50 --no-pager -``` +The sound files are installed under: +~~~text +/sounds/ +~~~ -### Log retention +The services use ALSA "aplay" to play the installed WAV files. -The installer configures systemd-journald with bounded retention so service logs cannot grow without limit: +## Logs -- Maximum persistent journal size: **200 MB** -- Keep at least **500 MB** free on the filesystem -- Maximum runtime journal size: **100 MB** -- Maximum journal age: **30 days** -- Existing journal data older than 30 days or beyond the size limit is pruned during installation. +The installer configures systemd-journald with bounded retention: -The retention limits apply to the system journal as a whole, not only GLaDOS services. +- Maximum persistent journal size: 200 MB +- Keep at least 500 MB free +- Maximum runtime journal size: 100 MB +- Maximum journal age: 30 days -## Display +These limits apply to the system journal as a whole. -The Waveshare 1.28-inch LCD uses the GC9A01 controller over SPI. +## Development -Current wiring: +Create a development environment with [uv](https://docs.astral.sh/uv/): -| Display | Raspberry Pi | -|---|---| -| VCC | 3.3V — pin 1 | -| GND | GND — pin 6 | -| DIN | GPIO10 / MOSI — pin 19 | -| CLK | GPIO11 / SCLK — pin 23 | -| CS | GPIO8 / CE0 — pin 24 | -| DC/DS | GPIO25 — pin 22 | -| RST | GPIO27 — pin 13 | -| BL | GPIO18 — pin 12 | +~~~bash +uv sync --dev +~~~ -SPI must be enabled on the Raspberry Pi. +Run the quality checks locally: -The display has been verified using the official Waveshare Python driver and example. +~~~bash +uv run ruff check . +uv run ruff format --check . +uv run ty check +~~~ -The display renderer uses the 240×240 GC9A01 window with 12-bit RGB444 pixel transfers. Animation frames are precomputed and only the changed bounding region between frames is transmitted, reducing SPI traffic while preserving the full display area. The speaking animation opens fully before reversing. +Run the test suite and enforce the CI coverage requirement: -The GLaDOS display service runs at boot and follows LVA events: +~~~bash +uv run pytest --cov=display --cov-report=term-missing --cov-fail-under=95 +~~~ -- Wake word → aperture opening -- Listening → listening animation -- Thinking → thinking animation -- Speaking → speaking animation -- Idle → idle aperture -- Pipeline error → brief error flash -- LVA/HA disconnected → connection-lost pulse +The same checks run automatically in GitHub Actions for pushes to main and pull requests. -The service automatically reconnects if LVA restarts. +The repository is intended to contain portable project configuration and installation logic. Machine-specific credentials, Docker volumes, generated wake-word files, local service state, and other host-specific data should not be committed. -## Repository Layout +## Repository layout -```text +~~~text GLaDOS/ -├── README.md -├── services/ -│ ├── glados-powerup.service -│ ├── glados-wakeup.service -│ └── glados-display.service +├── .github/ +│ └── workflows/ +├── display/ +│ ├── assets/ +│ ├── driver.py +│ ├── glados_display.py +│ └── states.py ├── scripts/ -│ ├── install.sh -│ ├── install-services.sh │ ├── download-sounds.sh -│ └── install-display.sh -└── sounds/ -``` - -Additional display, animation, configuration, and hardware-control components will be added as development continues. +│ ├── install-display.sh +│ ├── install-services.sh +│ └── install.sh +├── services/ +│ ├── glados-display.service +│ ├── glados-powerup.service +│ └── glados-wakeup.service +├── sounds/ +├── tests/ +├── pyproject.toml +└── README.md +~~~ ## Credits -This project builds on the work of **Mr. Volt (DJ Harrigan / @mr.v0lt)** and his GLaDOS project. - -We are using and adapting elements from his work, including STEP/3D design files, code, and hardware/component approaches. His original project was a major reference for the physical design and implementation of this build. - -- YouTube: https://www.youtube.com/watch?v=W9VFbfcogbA -- YouTube channel: https://www.youtube.com/c/MrVolt -- Instagram: https://www.instagram.com/mr.v0lt/ - -Please refer to the original project and its associated files for the applicable licensing and attribution requirements. This repository does not claim ownership of Mr. Volt's original work. +This project builds on the work of **Mr. Volt (DJ Harrigan / @mr.v0lt)** and his GLaDOS project. The physical design and some hardware/component approaches were inspired by that work. -## Software & AI Components +- [Mr. Volt YouTube](https://www.youtube.com/watch?v=W9VFbfcogbA) +- [Mr. Volt YouTube channel](https://www.youtube.com/c/MrVolt) +- [Mr. Volt Instagram](https://www.instagram.com/mr.v0lt/) -The GLaDOS voice pipeline is built from several open-source projects and model ecosystems: +Please refer to the original project and its associated files for applicable licensing and attribution requirements. -- **[wyoming-glados](https://github.com/nalf3in/wyoming-glados)** — Wyoming protocol server used to expose the GLaDOS TTS engine to Home Assistant. It is based on the GLaDOS TTS engine from R2D2FISH and is MIT licensed. -- **[wyoming-whisper-trt](https://github.com/JonahMMay/wyoming-whisper-trt)** — Wyoming-compatible OpenAI Whisper STT server accelerated with NVIDIA TensorRT. This project is maintained separately by Jonah May. -- **[llama.cpp](https://github.com/ggml-org/llama.cpp)** — Local LLM inference engine providing the OpenAI-compatible inference endpoint used by the Home Assistant voice pipeline. llama.cpp is MIT licensed. -- **[Local OpenAI LLM](https://github.com/skye-harris/hass_local_openai_llm)** — Home Assistant custom integration used to connect Assist to the local OpenAI-compatible llama.cpp server. -- **Gemma 4 E4B IT QAT** — The local language model used with llama.cpp and the Local OpenAI LLM integration. Gemma models are subject to Google's Gemma Terms of Use; the project license does not apply to the model weights. - -These dependencies are external projects and retain their own licenses and attribution requirements. The MIT license in this repository applies only to original GLaDOS project code and other material that we own and choose to release under that license. Third-party files, models, designs, and other contributed material remain subject to their respective licenses. - -## Development +## Third-party software -This project is being developed incrementally on a Raspberry Pi 4 running Debian. +This project relies on external software and services, including: -The goal is to keep the installation reproducible so the physical GLaDOS head can be rebuilt without relying on undocumented manual setup steps. +- [Linux Voice Assistant](https://github.com/OHF-Voice/linux-voice-assistant) +- [Home Assistant](https://www.home-assistant.io/) +- The Waveshare GC9A01 Python driver +- Python, Pillow, NumPy, and websockets -As components move from experimentation into the stable configuration, their installation and configuration should be represented in this repository. +Third-party projects retain their own licenses and attribution requirements. The MIT license in this repository applies to original material released by this project. ## License -This project is licensed under the [MIT License](LICENSE). \ No newline at end of file +This project is licensed under the [MIT License](LICENSE). From 6836c0cb391a9acfc78568fb221c25a103326dbc Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:33:15 -0500 Subject: [PATCH 28/36] Fix pytest lint errors --- tests/test_display.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_display.py b/tests/test_display.py index c2728a3..b179b77 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -14,7 +14,6 @@ ACTIVE_STATES, FRAME_DURATION, GladosDisplay, - SEQUENCES, ) from display.states import DisplayState @@ -67,7 +66,7 @@ def test_state_changes_reset_animation(display, monkeypatch): (DisplayState.SPEAKING, FRAME_DURATION * 9.1, 7), ], ) -def test_frame_at(display, state, elapsed): +def test_frame_at(display, state, elapsed, expected): display.state = state display.state_started = 100.0 frame, next_due = display._frame_at(100.0 + elapsed) From e3521ff69872e9d3311147780c98b09a40739647 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:33:57 -0500 Subject: [PATCH 29/36] Configure pytest import path --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d888cdf..3a2e94f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,3 +39,6 @@ root = ["."] [tool.ty.src] include = ["display"] + +[tool.pytest.ini_options] +pythonpath = ["."] From 77908ee670b3c886b75356477141699401bccd2e Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:34:05 -0500 Subject: [PATCH 30/36] Add pytest asyncio support --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3a2e94f..955f236 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dev = [ "ty>=0.0.1,<0.1", "pytest>=8,<9", "pytest-cov>=6,<8", + "pytest-asyncio>=1,<2", ] [tool.uv] From 303d3b0f0bd57a37d174900ee2bd7791c038e5c0 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:35:15 -0500 Subject: [PATCH 31/36] Expand display tests to meet coverage target --- tests/test_display.py | 123 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/tests/test_display.py b/tests/test_display.py index b179b77..b995798 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -146,7 +146,10 @@ def test_render_and_error_paths(display, monkeypatch): async def test_heartbeat_warns_on_lag(display, monkeypatch, caplog): sleeps = iter([None, asyncio.CancelledError()]) expected = iter([10.0, 10.5]) - monkeypatch.setattr("display.glados_display.time.monotonic", lambda: next(expected)) + monkeypatch.setattr( + "display.glados_display.time", + SimpleNamespace(monotonic=lambda: next(expected)), + ) async def fake_sleep(_): value = next(sleeps) @@ -262,7 +265,7 @@ def test_driver_rgb444(): driver = object.__new__(driver_module.DisplayDriver) driver._lcd = SimpleNamespace(np=np) image = Image.new("RGB", (2, 1), (255, 128, 16)) - assert driver._rgb444(image) == bytes([0xFF, 0xF8, 0x10]) + assert driver._rgb444(image) == bytes([0xF8, 0x1F, 0x81]) def test_driver_rgb444_odd_pixels(): @@ -305,7 +308,7 @@ def test_driver_show_changed_region_and_cache(): driver.show(second) calls = driver._lcd.SPI.writebytes2.call_count driver.show(second) - assert driver._lcd.SPI.writebytes2.call_count == calls + 1 + assert driver._lcd.SPI.writebytes2.call_count == calls third = second.copy() third.putpixel((21, 30), (255, 214, 0)) @@ -337,3 +340,117 @@ def test_close_handles_driver_error(display, caplog): display.driver.close = MagicMock(side_effect=RuntimeError("boom")) display.close() assert "Error while closing display" in caplog.text + + +def test_driver_init_and_lifecycle(monkeypatch): + class FakeLCD: + def __init__(self, spi_freq): + self.spi_freq = spi_freq + self.np = np + self.DC_PIN = 25 + self.SPI = MagicMock() + + def Init(self): + self.initialized = True + + def bl_DutyCycle(self, value): + self.duty = value + + def command(self, value): + self.command_value = value + + def data(self, value): + self.data_value = value + + def clear(self): + self.cleared = True + + fake_module = SimpleNamespace(LCD_1inch28=FakeLCD) + monkeypatch.setitem(__import__("sys").modules, "lib", fake_module) + monkeypatch.setenv("GLADOS_SPI_FREQ", "40000000") + + driver = driver_module.DisplayDriver() + assert driver._lcd.spi_freq == 40000000 + assert driver._lcd.command_value == 0x3A + assert driver._lcd.data_value == 0x03 + driver.clear() + driver.close() + assert driver._lcd.duty == 0 + + +def test_driver_init_requires_waveshare_driver(monkeypatch): + monkeypatch.setitem(__import__("sys").modules, "lib", SimpleNamespace()) + with pytest.raises(RuntimeError, match="Waveshare driver not installed"): + driver_module.DisplayDriver() + + +@pytest.mark.asyncio +async def test_render_loop_error_state_uses_fast_delay(display): + display.state = DisplayState.ERROR + sleeps = [] + + async def stop(delay): + sleeps.append(delay) + raise asyncio.CancelledError + + original_sleep = asyncio.sleep + try: + asyncio.sleep = stop + with pytest.raises(asyncio.CancelledError): + await display._render_loop() + finally: + asyncio.sleep = original_sleep + + assert sleeps == [0.05] + + +@pytest.mark.asyncio +async def test_render_loop_rethrows_render_error(display): + display.render = MagicMock(side_effect=RuntimeError("boom")) + + with pytest.raises(RuntimeError, match="boom"): + await display._render_loop() + + +@pytest.mark.asyncio +async def test_run_reconnect_error_path(display, monkeypatch): + async def stop(_): + raise asyncio.CancelledError + + def connect(*_args, **_kwargs): + raise RuntimeError("connection failed") + + async def idle_task(): + await asyncio.Event().wait() + + monkeypatch.setattr("display.glados_display.websockets.connect", connect) + monkeypatch.setattr("display.glados_display.asyncio.sleep", stop) + monkeypatch.setattr(display, "_render_loop", idle_task) + monkeypatch.setattr(display, "_event_loop_heartbeat", idle_task) + + with pytest.raises(asyncio.CancelledError): + await display.run() + + assert display.state == DisplayState.ERROR + assert display.connection_lost is True + + +def test_demo_exits_cleanly_on_keyboard_interrupt(monkeypatch): + fake_display = MagicMock() + monkeypatch.setattr( + "display.glados_display.GladosDisplay", + MagicMock(return_value=fake_display), + ) + + def stop(_): + raise KeyboardInterrupt + + monkeypatch.setattr( + "display.glados_display.time", + SimpleNamespace(monotonic=lambda: 0.0, sleep=stop), + ) + + from display.glados_display import demo + + demo() + fake_display.close.assert_called_once() From 380144fd7196d795ee70c92e9287153f2ef1595c Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:36:10 -0500 Subject: [PATCH 32/36] Fix hardware driver test expectations --- tests/test_display.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_display.py b/tests/test_display.py index b995798..53c9f43 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -313,7 +313,7 @@ def test_driver_show_changed_region_and_cache(): third = second.copy() third.putpixel((21, 30), (255, 214, 0)) driver.show(third) - assert driver._lcd.SetWindows.call_args.args == (21, 30, 22, 31) + assert driver._lcd.SetWindows.call_args.args == (21, 30, 23, 31) def test_driver_show_rejects_wrong_size(): @@ -365,7 +365,7 @@ def data(self, value): def clear(self): self.cleared = True - fake_module = SimpleNamespace(LCD_1inch28=FakeLCD) + fake_module = SimpleNamespace(LCD_1inch28=SimpleNamespace(LCD_1inch28=FakeLCD)) monkeypatch.setitem(__import__("sys").modules, "lib", fake_module) monkeypatch.setenv("GLADOS_SPI_FREQ", "40000000") From f4c5e6a53f16aa2a12c7125d8d306d3afd3455b5 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:37:05 -0500 Subject: [PATCH 33/36] Fix portable service installation paths --- scripts/install-services.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/install-services.sh b/scripts/install-services.sh index b363ed1..ab00c46 100755 --- a/scripts/install-services.sh +++ b/scripts/install-services.sh @@ -3,14 +3,15 @@ set -euo pipefail INSTALL_ROOT="${INSTALL_ROOT:-/opt/glados}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALL_USER="${GLADOS_USER:-${SUDO_USER:-$(id -un)}}" echo "Installing GLaDOS files to ${INSTALL_ROOT}..." -sudo mkdir -p "\${INSTALL_ROOT}/sounds" -sudo mkdir -p "\${INSTALL_ROOT}/services" +sudo mkdir -p "${INSTALL_ROOT}/sounds" +sudo mkdir -p "${INSTALL_ROOT}/services" -sudo cp -a "\${REPO_ROOT}/services/." "\${INSTALL_ROOT}/services/" -sudo cp -a "\${REPO_ROOT}/sounds/." "\${INSTALL_ROOT}/sounds/" 2>/dev/null || true +sudo cp -a "${REPO_ROOT}/services/." "${INSTALL_ROOT}/services/" +sudo cp -a "${REPO_ROOT}/sounds/." "${INSTALL_ROOT}/sounds/" 2>/dev/null || true # Keep systemd journal growth bounded so the Pi cannot fill its storage with # service logs. These limits apply to the system journal as a whole. @@ -34,7 +35,7 @@ sudo rm -f /etc/systemd/system/glados-boot-sounds.service sudo install -m 0644 "${REPO_ROOT}/services/glados-powerup.service" /etc/systemd/system/glados-powerup.service sudo install -m 0644 "${REPO_ROOT}/services/glados-wakeup.service" /etc/systemd/system/glados-wakeup.service -sed "s/@GLADOS_USER@/${INSTALL_USER}/g" "${REPO_ROOT}/services/glados-display.service" | sudo tee /etc/systemd/system/glados-display.service >/dev/null +sed -e "s|@GLADOS_USER@|${INSTALL_USER}|g" -e "s|@INSTALL_ROOT@|${INSTALL_ROOT}|g" "${REPO_ROOT}/services/glados-display.service" | sudo tee /etc/systemd/system/glados-display.service >/dev/null sudo systemctl daemon-reload sudo systemctl enable glados-powerup.service From 29dab760ea4f77fa02b461fabc31a966ee888ca3 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:37:10 -0500 Subject: [PATCH 34/36] Parameterize display service install root --- services/glados-display.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/glados-display.service b/services/glados-display.service index ec60411..122f538 100644 --- a/services/glados-display.service +++ b/services/glados-display.service @@ -7,7 +7,7 @@ Wants=network-online.target Type=simple User=@GLADOS_USER@ Group=@GLADOS_USER@ -WorkingDirectory=/opt/glados +WorkingDirectory=@INSTALL_ROOT@ Environment=PYTHONUNBUFFERED=1 Environment=LVA_WS_URL=ws://127.0.0.1:6055 ExecStart=/usr/bin/python3 -m display.glados_display From 3cf28ab0dcf1b3c7e98d588d0216b85397081ea7 Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:37:55 -0500 Subject: [PATCH 35/36] Clarify hardware and LVA prerequisites --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 42372be..a4c1c06 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ For the current Waveshare GC9A01 module: | RST | GPIO27 — pin 13 | | BL | GPIO18 — pin 12 | -SPI must be enabled in the Raspberry Pi OS configuration. +SPI must be enabled before installing the display software. On Raspberry Pi OS, run:\n\n~~~bash\nsudo raspi-config\n~~~\n\nSelect **Interface Options → SPI** and enable it, then reboot if prompted. Verify that the SPI device exists before continuing:\n\n~~~bash\nls /dev/spidev0.*\n~~~ The display implementation uses the official Waveshare Python driver as a hardware dependency. The driver is downloaded by the display installation script rather than committed to this repository. @@ -92,7 +92,7 @@ If pactl is not available, install/configure the host's PipeWire/PulseAudio tool ### LVA -Install and configure [Linux Voice Assistant](https://github.com/OHF-Voice/linux-voice-assistant) separately. +Install and configure [Linux Voice Assistant](https://github.com/OHF-Voice/linux-voice-assistant) separately. Follow its installation documentation for Docker, Compose, audio access, and LVA configuration rather than duplicating those instructions here. LVA must: From da55996567527fe6027f76793252ce2da239d0ad Mon Sep 17 00:00:00 2001 From: Jonah May <119529402+JonahMMay@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:45:26 -0500 Subject: [PATCH 36/36] Fix duplicate CI runs on pull requests --- .github/workflows/python-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml index 9ebd929..152aea1 100644 --- a/.github/workflows/python-quality.yml +++ b/.github/workflows/python-quality.yml @@ -2,7 +2,7 @@ name: Python quality on: push: - branches: [main, "ci/**"] + branches: [main] pull_request: permissions: