diff --git a/.github/workflows/python-quality.yml b/.github/workflows/python-quality.yml new file mode 100644 index 0000000..152aea1 --- /dev/null +++ b/.github/workflows/python-quality.yml @@ -0,0 +1,37 @@ +name: Python quality + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + python-version: "3.13" + + - name: Install Python dependencies + 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: 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 diff --git a/README.md b/README.md index f90c99e..a4c1c06 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 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~~~ -## 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. Follow its installation documentation for Docker, Compose, audio access, and LVA configuration rather than duplicating those instructions here. -### 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). diff --git a/display/driver.py b/display/driver.py index 5117e20..ef221f7 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 import sys import time -import os +from pathlib import Path + +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 + 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 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,7 +41,7 @@ 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) @@ -60,7 +65,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 +124,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) diff --git a/display/glados_display.py b/display/glados_display.py index d070fd9..ba494c7 100644 --- a/display/glados_display.py +++ b/display/glados_display.py @@ -6,17 +6,12 @@ import os import time from pathlib import Path -from typing import Optional -from PIL import Image 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") @@ -53,7 +48,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 +119,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 +168,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 +180,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 +263,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) 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" diff --git a/display/test_display.py b/display/test_display.py deleted file mode 100644 index 1868ba1..0000000 --- a/display/test_display.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Physical display smoke test.""" -from glados_display import demo - -if __name__ == "__main__": - demo() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..955f236 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[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", + "pytest>=8,<9", + "pytest-cov>=6,<8", + "pytest-asyncio>=1,<2", +] + +[tool.uv] +package = false + +[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"] + +[tool.pytest.ini_options] +pythonpath = ["."] 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" diff --git a/scripts/install-services.sh b/scripts/install-services.sh index d427dee..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 -sudo install -m 0644 "${REPO_ROOT}/services/glados-display.service" /etc/systemd/system/glados-display.service +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 diff --git a/services/glados-display.service b/services/glados-display.service index 6b90b14..122f538 100644 --- a/services/glados-display.service +++ b/services/glados-display.service @@ -5,9 +5,9 @@ Wants=network-online.target [Service] Type=simple -User=administrator -Group=administrator -WorkingDirectory=/opt/glados +User=@GLADOS_USER@ +Group=@GLADOS_USER@ +WorkingDirectory=@INSTALL_ROOT@ Environment=PYTHONUNBUFFERED=1 Environment=LVA_WS_URL=ws://127.0.0.1:6055 ExecStart=/usr/bin/python3 -m display.glados_display diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..53c9f43 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,456 @@ +"""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, +) +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, expected): + 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", + SimpleNamespace(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([0xF8, 0x1F, 0x81]) + + +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 + + third = second.copy() + third.putpixel((21, 30), (255, 214, 0)) + driver.show(third) + assert driver._lcd.SetWindows.call_args.args == (21, 30, 23, 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 + + +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=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()