Skip to content

Latest commit

 

History

180 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Magic Mirror Supervisor

Original Project Created in 2025 by Chris Heder (GitHub: @chrisron95).


This Python-based Magic Mirror Supervisor manages and automates various functionalities for a Magic Mirror setup. The Magic Mirror is a 42" TV with a mirrored acrylic sheet and an IR touch screen overlay. It’s powered by a Raspberry Pi 4. I have the MagicMirror2 software installed, but I prefer to run a Chromium kiosk browser displaying a Home Assistant dashboard.

This project includes features like TV management, system monitoring, and Home Assistant integration. You can control it via physical buttons or through Home Assistant. It also allows you to make changes to your Magic Mirror setup easily, supporting both automated system control and remote management via Home Assistant.

Key Features:

  • TV Control: Power on/off the TV, switch between inputs (e.g., Raspberry Pi vs HDMI), monitor TV status.
  • Home Assistant Integration: Auto-discovery of devices and sensors for controlling and monitoring via MQTT.
  • System Monitoring: Track CPU temperature, memory usage, disk space, network IP address, etc.
  • GPIO Button Control: Use physical buttons for common actions (e.g., reboot, shutdown, update, switch apps).
  • Volume Control: A Home Assistant slider for the Pi's own audio output (CEC volume control isn't reliable enough on most TVs to be worth it), kept in sync even when changed from the Pi's own system tray.
  • App Switching: Toggle between Magic Mirror and Home Assistant interfaces.
  • System Management: Reboot, shutdown, update, and pull the latest repo changes via button press.

Table of Contents


Forking and Customization

This project is highly customizable. Feel free to fork this repository to tailor it to your specific needs! Here’s how you can do that:

Steps to Fork and Customize:

  1. Fork the Repo:

  2. Clone Your Fork:

    • Clone your fork to your local machine:

      git clone https://github.com/your-username/magic-mirror-supervisor.git
      cd magic-mirror-supervisor
  3. Make Custom Changes:

    • You can now modify the code, configuration files, and entities to suit your needs. For example, you can add more buttons, change GPIO pin assignments, or modify how the TV is controlled.
  4. Push Changes:

    • Once you've made your customizations, push them back to your fork:

      git commit -am "Customized for my setup"
      git push origin main
  5. Update Your Pi:

    • To apply your changes on your Raspberry Pi, SSH into your Pi and navigate to your repository directory:

      ssh pi@your-pi-ip
      cd /home/pi/magic-mirror-supervisor  # Adjust path if necessary
    • Pull the latest changes from your fork:

      git pull origin main
    • If you set up a button for updating the repo and restarting the service, you can press that button to pull the changes and restart the supervisor automatically. Otherwise, restart the service manually:

      sudo systemctl restart magic-mirror-supervisor.service

Prerequisites

Before getting started, please ensure the following are already set up:

  • Home Assistant: Must be installed and configured with MQTT enabled.
  • MQTT Broker: Ensure you have an MQTT broker running (e.g., Mosquitto).
  • Raspberry Pi: With Raspberry Pi OS installed and connected to your network.
  • MagicMirror2: Already set up on the Raspberry Pi for the Magic Mirror interface.
  • IR Touch Screen Overlay: The setup assumes you have an IR touch screen overlay for the mirror, such as the IR Touch Screen on Amazon that makes it a touchscreen interface.
  • grim (optional): Only needed if an app in apps.yaml uses liveness_check (screenshot-based freeze detection). Install with sudo apt install grim.
  • uxplay (optional): Only needed for the built-in uxplay entry in services.yaml (AirPlay mirroring) — see UxPlay for install instructions. Remove that entry (or replace it with your own service) if you don't need AirPlay.
  • GTK/gtk-layer-shell: Powers the on-screen touch-button popup (app/button_popup.py, used by Supervisor.app_selector). Install with sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-gtklayershell-0.1.
  • mako: Wayland-native notification daemon backing Supervisor.notify. Setup:
    • Install: sudo apt install mako-notifier
    • Mask its systemd service, since it won't autostart on a bare auto-login labwc session: systemctl --user mask mako.service
    • Launch it yourself instead — add mako & to ~/.config/labwc/autostart (make sure that file is executable: chmod +x)
    • In ~/.config/mako/config, set layer=overlay (so notifications render above the fullscreen kiosk) and a default-timeout (so they auto-dismiss) — mako's own defaults do neither
  • wtype: Sends the F5 keypress for the "Refresh Kiosk" button (Supervisor.refresh_kiosk). Install with sudo apt install wtype.
  • wvkbd + python3-pyatspi: On-screen keyboard for the kiosk apps, backed by the onscreen_keyboard service (app/keyboard_watcher.py). Install with sudo apt install wvkbd python3-pyatspi. Replaces onboard, which doesn't work under labwc (X11 toplevel windows fight for focus with windowed apps) — onboard no longer needs to be installed.

Installation

  1. Clone the Repository:

    Replace the repository URL with yours if you forked your own version

    git clone https://github.com/chrisron95/magic-mirror-supervisor.git
    cd magic-mirror-supervisor
  2. Set up a Python Virtual Environment (Recommended):

    Create a Python virtual environment:

    python3 -m venv .venv

    Important: To ensure your virtual environment works correctly with system packages, you must set include-system-site-packages = True in the .venv/pyvenv.cfg file.

    Edit the .venv/pyvenv.cfg file:

    nano .venv/pyvenv.cfg

    Add/modify the following line:

    include-system-site-packages = True

    After making this change, save and close the file.

    Then, activate the virtual environment:

    source .venv/bin/activate  # On Windows: .venv\Scripts\activate
  3. Install Required Dependencies:

    Use the requirements.txt file to install necessary Python libraries:

    pip install -r requirements.txt
  4. Set Up Systemd Service (only after the script is working):

    Note: You should follow these steps after the script is working correctly for you. Since there can only be one instance of this script running (due to GPIO pins alredy being in use), it is best to ensure everything is working before setting it up as a system service.

    • Create the systemd service file at /lib/systemd/system/magic-mirror-supervisor.service:

      sudo nano /lib/systemd/system/magic-mirror-supervisor.service
    • Paste the following configuration into the file:

      Note: replace pi with your Pi's username, and 1000 with that user's UID (id -u <username>)

      [Unit]
      Description=Magic Mirror Supervisor
      After=multi-user.target
      
      [Service]
      Type=idle
      ExecStart=/home/pi/magic-mirror-supervisor/.venv/bin/python3 /home/pi/magic-mirror-supervisor/main.py
      Environment=DISPLAY=:0
      Environment=XDG_RUNTIME_DIR=/run/user/1000
      WorkingDirectory=/home/pi/magic-mirror-supervisor
      User=pi
      
      [Install]
      WantedBy=multi-user.target
    • Enable and start the service:

      sudo systemctl daemon-reload
      sudo systemctl enable magic-mirror-supervisor.service
      sudo systemctl start magic-mirror-supervisor.service

      To monitor the live logs of the systemd service, run the following command:

      journalctl -f -u magic-mirror-supervisor.service

Configuration

The system configuration is handled through YAML files under config/:

config/config.yaml

This file contains general configuration like the device name, model, logging level, and the fallback app to auto-start at boot.

name: "Magic Mirror"
user_home: "/home/chris"
manufacturer: "Raspberry Pi"
model: "4 Model B"
log_level: "INFO"
default_app: "homeassistant_mirror_dashboard"
tv_inputs:
  rPi:
    name: "Raspberry Pi"
    address: "2.0.0.0"
  hdmi:
    name: "HDMI 3"
    address: "3.0.0.0"
  • name: Name of your device as it appears in Home Assistant.
  • user_home: Absolute path to the Pi user's home directory. apps.yaml can reference it via {{user_home}} instead of hardcoding a path — used for things like the Chromium profile and the MagicMirror install location. Optional; defaults to whichever user the supervisor process runs as.
  • log_level: Set the logging level (e.g., INFO, DEBUG).
  • default_app: Which app (from apps.yaml) to start at boot if nothing's been selected yet via Home Assistant. See entities.yaml and apps.yaml.
  • tv_inputs: The two switchable TV inputs, by CEC physical address — run echo 'scan' | cec-client -s -d 1 to find these for your own TV/wiring (each device's address: field). rPi and hdmi are fixed keys the code looks up directly; name is what's shown in Home Assistant. This is optional — omit it to use the defaults shown above. The "TV Input" select automatically swaps the hdmi input's name for whatever CEC-aware device (e.g. an Apple TV) is actually detected at that address, falling back to the configured name when nothing CEC-capable is connected there — a non-CEC device like a laptop is invisible to a CEC scan entirely, so it'll always show the fallback name.

config/secrets.yaml

This file stores sensitive data, such as MQTT credentials and internal URLs/IPs. It's gitignored — never commit it. Any key in here can be referenced from apps.yaml (or elsewhere) via {{secrets.<key>}}, e.g. {{secrets.ha_url}}.

mqtt_broker: "your-mqtt-broker.local"
mqtt_port: 1883
mqtt_username: "your-mqtt-username"
mqtt_password: "your-mqtt-password"
ha_url: "http://192.168.1.70:8123"
  • mqtt_broker: The hostname or IP address of your MQTT broker.
  • mqtt_port: The port for the MQTT broker (default is 1883).
  • mqtt_username and mqtt_password: Credentials for the MQTT broker.
  • ha_url: Scheme, host, and port of your Home Assistant instance (e.g. http://192.168.1.70:8123). Referenced from apps.yaml as {{secrets.ha_url}}, e.g. for the kiosk app's dashboard URL.

config/entities.yaml

This file defines the entities (buttons, sensors, switches, selects) that will be discovered in Home Assistant, and the method to use for them.

binary_sensors:
  - name: "TV Power"
    unique_id: "tv_power"
    state: "tv.check_power_status"

sensors:
  - name: "IP Address"
    unique_id: "ip_address"
    state: "utils.get_ip_address"
  - name: "CPU Temperature"
    unique_id: "cpu_temperature"
    state: "utils.get_cpu_temperature"
  - name: "Memory Usage"
    unique_id: "memory_usage"
    state: "utils.get_memory_usage"
  - name: "Disk Usage"
    unique_id: "disk_usage"
    state: "utils.get_disk_usage"
  - name: "Current App"
    unique_id: "current_app"
    state: "supervisor.get_current_app_display_name"
    attributes:
      uptime: "supervisor.get_current_app_uptime"

buttons:
  - name: "Reboot"
    unique_id: "reboot"
    callback: "utils.reboot"

  - name: "Shutdown"
    unique_id: "shutdown"
    callback: "utils.shutdown"

  - name: "Start MagicMirror App"
    unique_id: "start_magicmirror"
    callback: "supervisor.start_app"
    args: ["magicmirror2"]

selects:
  - name: "Default Startup App"
    unique_id: "default_app"
    options: "{{apps_all}}"
    callback: "set_default_app"

numbers:
  - name: "Volume"
    unique_id: "volume"
    state: "utils.get_volume"
    callback: "utils.set_volume"
    min: 0
    max: 100
    step: 1
    mode: "slider"
  • binary_sensors / sensors: Report device/system state (TV power, IP address, CPU temperature, Pi/Supervisor uptime, etc.) back to Home Assistant. A sensor can optionally declare attributes — a map of attribute name to dotted method path, resolved the same way state is (see "Current App"'s uptime above). Attributes are set once at startup like state, and also re-resolved periodically for any that change over time (currently just Current App's uptime, refreshed every 30s by Supervisor) — see HomeAssistantClient.refresh_sensor_attributes.
  • buttons: Defines actions that buttons can trigger, such as reboot, shutdown, or starting an app. args is optional and lets a button call a method with a fixed argument (e.g. supervisor.start_app("magicmirror2")).
  • numbers: HA slider/box entities backed by a state/callback dotted-path pair, same resolution as everything else. The built-in "Volume" entity controls the Pi's own audio output level via wpctl (PipeWire) — see Utils.get_volume/Utils.set_volume — since CEC volume control isn't reliable enough on most TVs to bother with. It stays in sync even when volume is changed outside the app (e.g. the Pi's own system tray): Utils watches pactl subscribe in the background and pushes the real value to Home Assistant whenever it changes.
  • selects: HA dropdown entities. The "Default Startup App" select lets you change which app auto-starts at boot without editing config.yaml; the choice is persisted in data/settings.yaml. Its options can be "{{apps_all}}" to auto-populate from apps.yaml — shown as each app's display name, with a "No Startup App" option (and default) meaning "don't auto-start anything" — or a plain list of specific app keys (e.g. ["homeassistant_mirror_dashboard", "magicmirror2"]) to hand-pick a subset instead. Either way, an optional default_option overrides the pre-selected choice; it must be the app's apps.yaml key (or "No Startup App"), not its display name. (The option is deliberately not called "None" — Home Assistant's MQTT integration treats that exact string as a reserved sentinel for "unknown" rather than a selectable value.) Note: unlike buttons/switches, a select's callback must be a plain Supervisor method name (e.g. "set_tv_input"), not a dotted path — selects don't support the tv./utils. prefix form.
  • "TV Input" select: switches between the Pi and the other physical HDMI port (see tv_inputs in config.yaml). Its options update live — the second option's name swaps automatically between the configured fallback (e.g. "HDMI 3") and whatever CEC-aware device is actually detected there (e.g. "Apple TV"), refreshed on the same background poll that keeps the "TV Current Input" sensor (which reports "Off" while the TV is off) accurate.

config/apps.yaml

This file defines the apps the supervisor can launch (Chromium kiosk, MagicMirror, or anything you add — a game, a photo slideshow, etc.), replacing what used to be separate systemd services for each. See the comments in the file itself for the schema; supervisor.start_app("name") and the buttons/selects above are how you trigger one.

An entry can either reference a built-in app type via app: "<type>" (defined in app/app_templates.py) and just supply the instance-specific bits — for the "kiosk" type, that's normally just url (and name) — or define everything directly (working_directory, environment, setup, background, command, restart, liveness_check), the way magicmirror2 does. Adding a second kiosk pointed at a different dashboard is just:

security_cam:
  app: "kiosk"
  name: "Security Camera"
  url: "http://192.168.1.70:8123/dashboard-camera/dashboard"

Any template field can also be overridden per-instance (e.g. a different liveness_check threshold for one specific kiosk). The "kiosk" type also accepts show_navigation: true to keep Chromium's omnibox/back/forward/reload UI visible (it's hidden by default via --kiosk). Adding a whole new type of app (not just another kiosk instance) means adding a new template to app/app_templates.py.

An app can optionally set liveness_check (interval / stale_after, in seconds) to catch a specific failure mode restart: true alone can't: a process that's still running but has hung (e.g. a frozen browser tab), rather than one that's actually exited. With it enabled, the supervisor periodically screenshots the display and restarts the app if the screen hasn't visibly changed for stale_after seconds — requires grim installed on the Pi.

Each app's stdout/stderr log under logs/ is capped at AppManager.MAX_LOG_BYTES (5 MB by default) and rotated to a single .1 backup when it's exceeded, so log growth stays bounded regardless of uptime or how chatty an app's console output is.

config/buttons.yaml

This file defines the physical GPIO buttons: their pin, and what happens on each kind of interaction.

buttons:
  - name: "Button 1"
    pin: 25
    hold_time: 1
    triggers:
      1: "tv.toggle_power"
      hold: ["tv.standby", "utils.shutdown"]

Each button has a triggers map keyed by press count (1, 2, 3, ...) or the literal "hold". A press count with no entry is simply ignored, so double/triple-press support is already there — just add a 2:/3: entry once you decide what it should do. A trigger's value is a dotted method path (e.g. "tv.toggle_power"), resolved the same way entities.yaml callbacks are, against the running tv/supervisor/utils instances — or a list of dotted paths to run in order (e.g. Button 1's hold: turn the TV off, then shut down). hold_time (seconds, default 1) is how long the button must be held before it counts as a hold instead of a press.

An optional hold_repeat: true makes the hold trigger fire repeatedly (every hold_time seconds) for as long as the button stays held, instead of just once — e.g. for a press-and-hold volume ramp via utils.volume_up/utils.volume_down. It defaults to false, so existing one-shot hold actions are unaffected; a short hold_time (e.g. 0.2) gives a snappier repeat cadence, since it also doubles as the interval between repeats.

config/services.yaml

This file defines independent background services — things that should just run in the background (or be toggled on/off), as opposed to apps.yaml's kiosk/MagicMirror entries, which are mutually exclusive (starting one stops whatever else is showing). UxPlay (AirPlay mirroring) is the built-in example, replacing what used to be its own systemd unit:

services:
  uxplay:
    name: "AirPlay"
    working_directory: "{{user_home}}"
    environment:
      DISPLAY: ":0.0"
      XAUTHORITY: "{{user_home}}/.Xauthority"
    command: "stdbuf -oL -eL uxplay -n MagicMirror -nh -fs -avdec -nofreeze -nohold"
    restart: true
    autostart: true
    restart_on_output: "raop_rtp_mirror->running is no longer true"

Any number of services can run at the same time as each other and as whatever app is currently showing — they're independent, not something you switch between. working_directory/environment/command/restart mean the same thing as a directly-defined apps.yaml entry (no templates, no setup/background/liveness_check); {{user_home}}, {{uid}}, and {{secrets.<key>}} are available the same way too. autostart: true starts the service when the supervisor boots (independent of network state), instead of waiting for it to be toggled on.

restart_on_output restarts the service the moment its own output contains the given text — used here because UxPlay never clears its mirrored window on its own when a client disconnects (confirmed via live logs: nothing happens between "Connection closed" and the process being killed), so instead of waiting for that, the supervisor forces a fresh process/window itself as soon as it sees the disconnect line. stdbuf -oL -eL (prefixed onto the whole command) is what makes any of this possible at all: a piped (non-terminal) stdout is fully block-buffered by default, so without it UxPlay's own output — and restart_on_output's ability to react to it — would only ever show up all at once when the process exits, not as it actually happens.

A service is wired up to Home Assistant as a switch in entities.yaml (see the "AirPlay" switch there) — its unique_id must match the service's key here, since Supervisor uses that to look up and push state changes back. There isn't a fully generic callback for this yet: adding a second independent service means adding a small start_<name>/stop_<name>/is_<name>_running trio to app/supervisor.py, mirroring start_uxplay/stop_uxplay/is_uxplay_running.

Unlike apps.yaml (where console output only goes to logs/<name>-app.log), a service's stdout/stderr also streams live into the supervisor's own log — so journalctl -f -u magic-mirror-supervisor.service shows UxPlay's own output (prefixed [uxplay]), not just the supervisor's, in addition to still being written to logs/uxplay.log.

AirPlay screen orientation and audio mode: two more selects control UxPlay itself — "AirPlay Orientation" (Normal/Rotate Right/Rotate Left/Upside Down, UxPlay's -r/-f flags; useful if you ever take the mirror off the wall and lay it flat) and "AirPlay Audio Mode" (Video & Audio/Audio Only, UxPlay's -vs 0, which suppresses video rendering while still playing audio — there's no equivalent flag for the reverse, video-only). Both flags combine into the same UxPlay command. UxPlay only applies either at launch — there's no live/mid-stream change — so changing either select restarts UxPlay immediately if it's already running, and both choices are persisted (data/settings.yaml) and re-applied on every future start, including autostart at boot.


Usage

  1. Run the Supervisor:

    After setting everything up, you can run the script manually to test if everything is working:

    python main.py

    This will start managing the Magic Mirror, checking and reporting TV state, monitoring buttons (immediately), and integration with Home Assistant.

  2. Control from Home Assistant:

    Once integrated, you can control and monitor the following via Home Assistant:

    • Switches: Control TV power, and toggle independent background services like AirPlay (UxPlay) on/off.
    • Sensors: Monitor system stats like IP address, CPU temperature, memory usage, and Pi/Supervisor uptime.
    • Buttons: Trigger actions like reboot, shutdown, or app switching.
    • Selects: Pick the default startup app, switch between running apps, or rotate the AirPlay orientation.
    • Numbers: Adjust the Pi's audio output volume via a slider.
  3. Control via Physical Buttons:

    The physical buttons connected to the Raspberry Pi perform various actions such as toggling TV power, switching between Magic Mirror and Home Assistant, stopping all applications to show the desktop, and more.

    Each button supports single, double, triple (or more) presses, plus a hold, disambiguated by the ButtonHandler class. Which action fires for which interaction is configured per-button in config/buttons.yaml.


Project Structure

magic-mirror-supervisor/
├── main.py                        # Entry point; wires everything together and runs the event loop
├── requirements.txt
├── app/                           # Application code
│   ├── tv.py                      # TV power/input control via HDMI-CEC
│   ├── buttons.py                 # GPIO button handling (press-count/hold) + config/buttons.yaml loader
│   ├── supervisor.py              # App switching, notifications, default-app selection
│   ├── apps.py                    # Launches/supervises the apps defined in config/apps.yaml
│   ├── app_templates.py           # Built-in app types (e.g. "kiosk") apps.yaml entries can reference
│   ├── services.py                # Launches/supervises the independent services in config/services.yaml
│   ├── process_utils.py           # Shared subprocess spawn/log-rotation/terminate logic (apps + services)
│   ├── home_assistant_client.py   # MQTT/Home Assistant discovery and entity sync
│   ├── settings_store.py          # Small persisted key/value store (data/settings.yaml)
│   └── utils.py                   # System stats and system actions (reboot, shutdown, updates)
├── config/                        # Deployment-specific configuration (see Configuration below)
│   ├── config.yaml
│   ├── secrets.yaml                (gitignored)
│   ├── entities.yaml
│   ├── apps.yaml
│   ├── buttons.yaml
│   └── services.yaml
├── data/
│   └── settings.yaml               (gitignored; written at runtime, e.g. the HA-selected default app)
├── logs/                           (gitignored; per-app stdout/stderr, size-capped and rotated)
└── sounds/                         # Audio assets
  • main.py: The main script that initializes and runs the Magic Mirror Supervisor, managing the TV, buttons, Home Assistant integration, and more.
  • app/tv.py: Handles TV operations like turning it on/off, switching inputs, and checking the power status.
  • app/buttons.py: Manages physical button interactions via GPIO — press-count (single/double/triple/...) and hold disambiguation, wired up from config/buttons.yaml.
  • app/supervisor.py: Handles higher-level actions like switching apps, refreshing the kiosk, and stopping apps.
  • app/apps.py: Starts, stops, and (if configured) auto-restarts the apps defined in config/apps.yaml — this is what replaced the old kiosk.service/magicmirror.service systemd units.
  • app/app_templates.py: Defines built-in app types (currently just "kiosk") so a new kiosk instance in apps.yaml only needs a url, not a full copy of the Chromium command/setup/environment.
  • app/services.py: Starts, stops, and (if configured) auto-restarts the independent background services defined in config/services.yaml (e.g. UxPlay/AirPlay) — unlike apps.py, any number can run at once, since they're toggled independently rather than switched between.
  • app/process_utils.py: The subprocess spawn (own process group, rotated log file) and terminate (SIGTERM then SIGKILL) logic shared by both apps.py and services.py.
  • app/home_assistant_client.py: Manages MQTT communication with Home Assistant, setting up sensors, buttons, switches, and selects.
  • app/settings_store.py: Persists small bits of runtime-changeable state (like the HA-selected default app) to data/settings.yaml, separate from the static config/ files.
  • app/utils.py: Provides utility functions like system stats (CPU temperature, memory usage), network connectivity checks, system actions (reboot, shutdown), and volume control (wpctl-backed, with a background pactl subscribe watcher to catch changes made outside the app).

About

Python script to manage my Magic Mirror and connect with Home Assistant

Topics

Resources

Stars

1 star

Watchers

2 watching

Forks

Used by

Contributors

Languages