Contents — 26 sections
Project Overview
A two-way mirror with a display behind it showing weather, calendar, transit and reminders — built so it is genuinely readable, genuinely private, and switches itself off when nobody is standing there.
A smart mirror is one of the few projects where the software is easy and the physics is not. Getting a browser to render a dashboard takes an afternoon. Getting text that is crisp and bright through a semi-reflective surface, in a bathroom, without the mirror looking like a dark grey rectangle when it is off, is where all the real decisions are.
The core component is a two-way mirror — a sheet of glass or acrylic with a partial metallic coating that reflects most incident light and transmits the rest. The transmission ratio is the fundamental trade-off. A 70/30 mirror (70 % reflected, 30 % transmitted) looks like a proper mirror and needs a very bright display behind it. A 50/50 looks slightly grey as a mirror but shows text beautifully. There is no setting that is excellent at both, and choosing deliberately is the single most consequential decision in the build.
Everything else follows from that constraint. The display must be bright, so an old laptop panel or a cheap monitor with its bezel removed is used at maximum backlight. The interface must be white or light grey text on pure black, because black pixels transmit no light and read as mirror — any coloured background becomes a washed-out haze. And font sizes need to be far larger than a normal dashboard, because contrast through the mirror is inherently reduced.
The controller is a Raspberry Pi running a full browser in kiosk mode, which is the pragmatic choice: you get real web rendering, real fonts, and access to any API you want with a few lines of JavaScript. A presence sensor blanks the display when nobody is there, which both saves power and — more importantly — means the mirror is a mirror when you are not using it.
What this project does
- Displays time, date, weather, forecast, calendar events, transit departures and a reminder list.
- Blanks the display when nobody has been detected for two minutes, so it reads as a plain mirror.
- Dims automatically with ambient light so it is not dazzling in a dark bathroom at 6 a.m.
- Pulls data from local and public APIs on a schedule, with a cached fallback when offline.
- Serves a small configuration page on the LAN so layout and feeds can be changed without a keyboard.
- Runs entirely on your own hardware with no third-party mirror service or account.
- Recovers automatically from a crash, a network outage or a power cut with no interaction.
Real-World Applications
| Setting | How it is used |
|---|---|
| Bathroom or hallway morning dashboard | The information you actually want while getting ready, in a place you are already looking. |
| Entrance-hall departure board | Live transit times at the door remove the "should I run" question entirely. |
| Family calendar and chores | A shared display in a shared space gets read; a shared calendar app does not. |
| Office reception | Meeting room status and visitor information without an obvious screen. |
| Gym or studio | A mirror is already required; the workout timer and metrics come free. |
| Accessible information display | Very large high-contrast type at a fixed location suits users who struggle with a phone. |
Deployment contexts where a build of this kind earns its keep.
Features & Capabilities
- Pure-black interface with high-contrast white type — the only design that reads well through a two-way mirror.
- Presence-gated display via mmWave or PIR, with a slow fade rather than an abrupt switch.
- Ambient light dimming using a BH1750, mapped through a perceptual curve.
- Offline cache so a network outage shows the last known data with a staleness indicator rather than an error.
- Modular widget layout defined in a single JSON file, editable from a phone.
- Systemd supervision with automatic restart, so a browser crash is invisible.
- Read-only root filesystem option so an unclean power cut cannot corrupt the SD card.
- No cloud dependency — every API key stays on the device and every request is made from it.
Difficulty, Time & Required Skills
| Attribute | Value |
|---|---|
| Difficulty level | Intermediate |
| Estimated completion time | 14–20 hours |
| Indicative build cost | ₹11,000 – ₹16,000 |
| Primary discipline | Smart Home |
| Reference platform | Raspberry Pi 4 Model B (4 GB) |
Skills you should have (or will pick up)
- Raspberry Pi OS setup and the command line
- HTML, CSS and enough JavaScript to fetch and render JSON
- systemd service files and autostart
- Basic woodworking or frame assembly
- Working with glass or acrylic safely
Bill of Materials
Every part below is commonly available from Indian and international hobby-electronics suppliers. Prices are indicative 2026 retail figures in Indian rupees and will drift — treat them as a budgeting guide, not a quotation.
| Component | Key specification | Qty | Approx. cost |
|---|---|---|---|
| Raspberry Pi 4 Model B (4 GB) Use an official 5 V 3 A supply — brown-outs from phone chargers corrupt SD cards. | Quad-core Cortex-A72 @ 1.8 GHz, 4 GB LPDDR4, Gigabit Ethernet, Wi-Fi 5, BT 5.0, 2× USB 3.0, 40-pin GPIO | 1 | ₹5,800 |
| BH1750 digital ambient light sensor Far more linear than an LDR — use it whenever you need real lux, not a relative value. | 1–65535 lx, 16-bit, ±20 %, spectral response close to the human eye | 1 | ₹140 |
| HC-SR501 PIR motion sensor Give it 60 s to settle after power-up or it fires false triggers. | 3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle | 1 | ₹80 |
| microSD card 32 GB A1 class For 24/7 loggers buy a high-endurance card — normal cards die in months. | A1 rated, 10 MB/s random write, UHS-I, endurance-grade recommended | 1 | ₹450 |
| 5 V 3 A regulated SMPS adapter Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 1 | ₹350 |
| Double-sided perfboard 7 × 9 cm + headers Solder female headers so the MCU can be swapped without desoldering. | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | 1 | ₹60 |
| Two-way acrylic mirror, 3 mm Acrylic is lighter, cheaper and safer than glass; glass is scratch-resistant and optically better. Choose deliberately. | 600 × 400 mm, 50/50 or 70/30 transmission | 1 | ₹2,400 |
| LCD monitor or laptop panel + driver board A second-hand monitor is usually cheaper than a bare panel plus driver board. | 15–24″, 1080p, bezel removable | 1 | ₹3,500 |
| Timber frame stock + black felt The felt lines the cavity — any reflective internal surface shows through as a grey patch. | 40 × 20 mm pine, self-adhesive felt | 1 | ₹600 |
| LD2410 mmWave presence sensor Detects a stationary person, which a PIR cannot. Worth the upgrade for a mirror. | 24 GHz, 0.75–6 m, UART | 1 | ₹550 |
| HDMI cable (right-angle) and short power leads | Low profile to fit the cavity depth | 1 | ₹300 |
Estimated total: ₹14,230, excluding tools, shipping and consumables.
Tools and consumables
- Soldering iron (temperature controlled, 350 °C) with 0.8 mm 60/40 or lead-free solder
- Digital multimeter — continuity, DC volts and current ranges
- Wire strippers, flush cutters and a small set of precision screwdrivers
- Heat-shrink tubing and a heat gun (or a lighter, carefully)
- A laptop with a USB port and the toolchain listed above
Hardware Specifications
| Part | Specification | Supply | Interface | Reference |
|---|---|---|---|---|
| Raspberry Pi 4 Model B (4 GB) | Quad-core Cortex-A72 @ 1.8 GHz, 4 GB LPDDR4, Gigabit Ethernet, Wi-Fi 5, BT 5.0, 2× USB 3.0, 40-pin GPIO | 5 V / 3 A USB-C | GPIO, SPI, I²C, UART, CSI, DSI | Datasheet |
| BH1750 digital ambient light sensor | 1–65535 lx, 16-bit, ±20 %, spectral response close to the human eye | 2.4–3.6 V | I²C (0x23/0x5C) | Datasheet |
| HC-SR501 PIR motion sensor | 3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle | 4.5–20 V | Digital high on motion | Datasheet |
| microSD card 32 GB A1 class | A1 rated, 10 MB/s random write, UHS-I, endurance-grade recommended | 3.3 V | SDIO / SPI | Datasheet |
| 5 V 3 A regulated SMPS adapter | 100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection | 5 V | DC barrel / USB | Datasheet |
| Double-sided perfboard 7 × 9 cm + headers | FR-4, 0.1″ pitch, plated through-holes, 24 × 18 grid | — | — | Datasheet |
Consolidated electrical and interface specifications for every active part in the build.
Power Budget & Supply Sizing
Add up the typical active current of every part, then size the supply with at least 50 % headroom so transmit bursts and motor inrush never brown out the controller.
| Load | Supply rail | Typical current (mA) | Notes |
|---|---|---|---|
| Raspberry Pi 4 Model B (4 GB) | 5 V / 3 A USB-C | 1200 | Use an official 5 V 3 A supply — brown-outs from phone chargers corrupt SD cards. |
| BH1750 digital ambient light sensor | 2.4–3.6 V | 0.19 | Far more linear than an LDR — use it whenever you need real lux, not a relative value. |
| HC-SR501 PIR motion sensor | 4.5–20 V | 0.05 | Give it 60 s to settle after power-up or it fires false triggers. |
| microSD card 32 GB A1 class | 3.3 V | 100 | For 24/7 loggers buy a high-endurance card — normal cards die in months. |
| 5 V 3 A regulated SMPS adapter | 5 V | 3000 | Measure the real output — many "3 A" adapters sag below 4.7 V at 2 A. |
Summed typical draw is 4300.24 mA. With a 1.5× design margin the supply should deliver at least 6500 mA continuously at the stated rail voltage.
Software Requirements & Development Environment
Reference toolchain: Raspberry Pi OS Bookworm (64-bit) + Chromium kiosk mode + Python 3.11. Anything newer normally works; anything older may lack the board definitions used here.
- Flash Raspberry Pi OS (64-bit) with Raspberry Pi Imager; pre-configure Wi-Fi, hostname and SSH in the Imager settings so the board comes up headless.
- Update first:
sudo apt update && sudo apt full-upgrade -y, then reboot. - Work inside a virtual environment —
python3 -m venv ~/venv && source ~/venv/bin/activate. Bookworm blocks system-widepip installby design. - Enable the buses you need with
sudo raspi-config→ Interface Options (I²C, SPI, Serial, Camera). - Develop over VS Code Remote-SSH so you edit on your laptop but run on the Pi.
Required libraries
| Library | Why it is needed | Install |
|---|---|---|
| Python 3.11+ | Runtime for the analysis, training and service code. | sudo apt install python3 python3-venv python3-pip |
| Flask 3.0+ | Minimal HTTP API and dashboard server. | pip install flask |
| gpiozero + lgpio 2.0+ | Readable GPIO API for the Raspberry Pi (Pi 5 compatible backend). | sudo apt install python3-gpiozero python3-lgpio |
| SQLite 3.45+ | Zero-configuration embedded database for local logs. | Bundled with Python (`import sqlite3`) |
Block Diagram
The block diagram shows the functional decomposition of the system — what senses, what decides, what acts, and where the data ends up.
Circuit Diagram & Wiring
Every signal line in the build is shown below, followed by a pin-by-pin connection table you can work through with a multimeter in hand.
| Peripheral | Peripheral pin | Controller pin | Signal |
|---|---|---|---|
| LD2410 presence sensor | TX / RX | GPIO 15 / 14 | UART 256000 baud |
| BH1750 light sensor | SDA / SCL | GPIO 2 / 3 | I²C at 0x23 |
| Optional PIR fallback | OUT | GPIO 17 | High on motion |
| Monitor via HDMI | HDMI | HDMI0 | Display output |
| Monitor backlight control | DDC/CI over HDMI | — | Software brightness |
| Status LED (build only) | Anode | GPIO 27 | Remove before final assembly |
Wire one row at a time and tick it off — most "it does not work" reports trace back to a single swapped pair.
Wiring explanation
- The cavity behind the mirror must be matte black. Any bare timber, any shiny screw head, any reflective cable will appear as a visible grey patch through the mirror. Line everything with black felt or paint it matte black.
- Mount the panel so its surface is as close to the mirror as possible — ideally touching through a thin felt gasket. Any air gap creates a secondary reflection and a visible ghost image offset from the text.
- Remove the monitor bezel entirely. A bezel edge inside the cavity shows as a hard rectangle outline through the mirror.
- Heat is a real issue in a sealed frame. A Pi 4 plus a monitor driver board in a closed cavity will reach 60 °C. Cut ventilation slots at the top and bottom of the frame, hidden behind the frame lip.
- Route the HDMI with a right-angle connector — a standard plug adds 25 mm of depth and is usually what forces a thicker frame than you wanted.
- The LD2410 mmWave sensor must be behind the mirror but not behind the metal-coated area if you use glass — a metallic coating attenuates 24 GHz significantly. Acrylic two-way mirror film is far more transparent to it.
System Architecture
Read the stack from the bottom up: physical hardware, the firmware that drives it, the transport that moves data off the device, and the software a human actually looks at.
Working Principle
A two-way mirror is a partially reflective coating — usually a very thin aluminium or silver layer — on a transparent substrate. It has no directional property at all, which is the most common misconception about it. What makes one side look like a mirror and the other like a window is purely the lighting ratio: the side that is brighter sees a reflection, and the side that is darker sees through. That is why a smart mirror works — the room is lit and the cavity is dark, so from the room you see a mirror, except where the bright display overcomes the reflection.
The transmission figure sets everything else. With a 70/30 mirror, only 30 % of the display's light reaches your eye, and it competes with 70 % of the room light reflecting back. To read comfortably the display must be substantially brighter than the reflected room light at that point. In a bright bathroom that requires a genuinely bright panel at full backlight. A 50/50 mirror halves the problem but leaves the mirror function noticeably dimmer than a real mirror.
That is why the interface must be black with white text. A black pixel emits nothing, so the mirror coating reflects normally and that area looks like ordinary mirror. A white pixel emits at full brightness and punches through. Any intermediate colour produces a grey haze that reads as neither. This is also why photographs, coloured charts and background images look terrible on a smart mirror even though they look fine on the same monitor.
The presence sensor choice is more interesting than it appears. A PIR detects change in infrared, so it detects a person walking in and then stops detecting them thirty seconds later while they stand still brushing their teeth — precisely when you want the display on. An LD2410 mmWave sensor transmits a 24 GHz signal and measures the Doppler shift and phase of the return, which detects the micro-movements of breathing. It reports a stationary person indefinitely, and that single difference transforms the experience.
Finally, ambient brightness mapping. Perceived brightness is roughly logarithmic in luminance, and ambient light in a bathroom spans four orders of magnitude between night and a sunny morning. A linear mapping from lux to backlight leaves the display either dazzling at night or invisible in daylight. Mapping through a logarithm, with a floor so it never goes fully dark while in use, is what makes it comfortable at both extremes.
The maths behind it
Contrast through a two-way mirror
Let T = transmission, R = reflection (T + R ≈ 1 − absorption)
Apparent display luminance = L_display × T
Apparent reflected room light = L_room × R
Contrast ratio at a white pixel:
C = (L_display × T + L_room × R) / (L_room × R)
70/30 mirror, 250 cd/m² panel, 150 cd/m² room reflection source:
display contribution = 250 × 0.30 = 75
reflected = 150 × 0.70 = 105
C = (75 + 105) / 105 = 1.71 ← poor, text looks washed out
50/50 mirror, same panel:
display = 125, reflected = 75
C = (125 + 75) / 75 = 2.67 ← readable
This is the entire argument for choosing 50/50 in a bright room.
Perceptual brightness mapping
Ambient range: 0.5 lx (night) to 2000 lx (sunny bathroom)
Backlight range: 8 % (minimum comfortable) to 100 %
b = b_min + (b_max − b_min) × log10(1 + lux/λ) / log10(1 + lux_max/λ)
With λ = 10, lux_max = 2000:
lux = 1 → b = 8 + 92 × 0.041/1.32 = 10.9 %
lux = 50 → b = 8 + 92 × 0.778/1.32 = 62.2 %
lux = 400 → b = 8 + 92 × 1.613/1.32 = 100.0 % (clamped)
A linear map would put 50 lx at 2.3 % — invisible.
Program Flowchart
The firmware is a single cooperative loop. Nothing blocks for long, so networking, sensing and the user interface all stay responsive.
Assembly Instructions
Build on a breadboard first and only commit to solder once the whole system has run for an hour without a fault.
Step-by-Step Implementation Guide
Work through these in order. Each step ends in something you can observe, so a failure is always localised to the step you just finished.
Prepare the Pi for kiosk operation
The goal is a machine that boots straight into a full-screen browser, never shows a cursor or a desktop, and recovers from anything without a keyboard.
bash01-setup.sh#!/usr/bin/env bash set -euo pipefail # Raspberry Pi OS Bookworm (64-bit), Wayland/labwc session. sudo apt update && sudo apt full-upgrade -y sudo apt install -y chromium-browser unclutter python3-venv python3-lgpio \ python3-smbus i2c-tools ddcutil # Enable I2C and the hardware UART; disable the serial console so the # LD2410 has the port to itself. sudo raspi-config nonint do_i2c 0 sudo raspi-config nonint do_serial_hw 0 sudo raspi-config nonint do_serial_cons 1 # Never blank the screen or start a screensaver — we control blanking. sudo tee /etc/X11/xorg.conf.d/10-blanking.conf >/dev/null <<'EOF' Section "ServerFlags" Option "BlankTime" "0" Option "StandbyTime" "0" Option "SuspendTime" "0" Option "OffTime" "0" EndSection EOF python3 -m venv ~/mirror-venv ~/mirror-venv/bin/pip install flask requests icalendar pytz smbus2 # Rotate the display if the panel is mounted portrait. # sudo sed -i 's/^display_rotate=.*//' /boot/firmware/config.txt # echo "display_rotate=1" | sudo tee -a /boot/firmware/config.txt echo "Reboot, then install the systemd units."do_serial_cons 1Disables the login console on the UART while leaving the hardware UART enabled. Without this the kernel prints boot messages to the same port the presence sensor uses, and the sensor never syncs.BlankTime 0 etc.The display must never blank on its own schedule, because the presence logic owns blanking. Two things independently deciding when to turn the screen off produces a mirror that flickers on and off unpredictably.ddcutilControls monitor backlight over DDC/CI, which is the correct way to dim an external panel. Software gamma dimming reduces contrast, which is exactly what you cannot afford through a mirror.venv rather than system pipBookworm marks the system Python as externally managed and refuses global pip installs. A virtual environment is not optional here.Write the data daemon with an offline cache
A dashboard that shows a stack-trace or a blank panel when the internet hiccups is worse than one that shows yesterday's weather with a small "stale" marker. Cache everything.
pythonmirror_daemon.py#!/usr/bin/env python3 """Smart mirror data daemon. Polls each feed on its own schedule, caches every successful response in SQLite, and serves the merged state to the browser over Server-Sent Events. A failed fetch never removes data — it only marks it stale. """ from __future__ import annotations import json import sqlite3 import threading import time from dataclasses import dataclass from pathlib import Path import requests from flask import Flask, Response, jsonify, request, send_from_directory DB = Path.home() / "mirror.db" CONF = Path.home() / "mirror-config.json" app = Flask(__name__, static_folder=str(Path.home() / "mirror-ui")) state: dict = {} state_lock = threading.Lock() @dataclass class Feed: key: str url: str period: int # seconds between polls parse: str # name of the transform to apply def db() -> sqlite3.Connection: con = sqlite3.connect(DB, check_same_thread=False) con.execute("CREATE TABLE IF NOT EXISTS cache" "(key TEXT PRIMARY KEY, payload TEXT, fetched REAL)") return con def cache_put(key: str, payload: dict) -> None: with db() as con: con.execute("INSERT OR REPLACE INTO cache VALUES (?,?,?)", (key, json.dumps(payload), time.time())) def cache_get(key: str) -> tuple[dict | None, float]: with db() as con: row = con.execute("SELECT payload, fetched FROM cache WHERE key=?", (key,)).fetchone() return (json.loads(row[0]), row[1]) if row else (None, 0.0) # ---- transforms ----------------------------------------------------- def parse_weather(raw: dict) -> dict: cur = raw["current"] daily = raw["daily"] return { "temp": round(cur["temperature_2m"]), "feels": round(cur["apparent_temperature"]), "code": cur["weather_code"], "wind": round(cur["wind_speed_10m"]), "today_min": round(daily["temperature_2m_min"][0]), "today_max": round(daily["temperature_2m_max"][0]), "rain_mm": round(daily["precipitation_sum"][0], 1), "forecast": [ {"day": d[:10], "min": round(lo), "max": round(hi)} for d, lo, hi in zip(daily["time"][1:4], daily["temperature_2m_min"][1:4], daily["temperature_2m_max"][1:4]) ], } TRANSFORMS = {"weather": parse_weather} def poll(feed: Feed) -> None: """One worker thread per feed. Never dies; never clears the cache.""" while True: try: r = requests.get(feed.url, timeout=12) r.raise_for_status() data = r.json() if feed.parse in TRANSFORMS: data = TRANSFORMS[feed.parse](data) cache_put(feed.key, data) with state_lock: state[feed.key] = {"data": data, "stale": False, "at": time.time()} except Exception as exc: # noqa: BLE001 cached, when = cache_get(feed.key) with state_lock: state[feed.key] = { "data": cached, "stale": True, "age_min": round((time.time() - when) / 60) if when else None, "error": type(exc).__name__, } time.sleep(feed.period) # ---- HTTP ----------------------------------------------------------- @app.route("/") def index(): return send_from_directory(app.static_folder, "index.html") @app.route("/api/state") def api_state(): with state_lock: return jsonify(state) @app.route("/api/events") def api_events(): """Server-Sent Events: the browser never polls, it just listens.""" def stream(): last = None while True: with state_lock: snapshot = json.dumps(state, default=str) if snapshot != last: last = snapshot yield f"data: {snapshot}\n\n" time.sleep(1) return Response(stream(), mimetype="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) @app.route("/api/config", methods=["GET", "POST"]) def api_config(): if request.method == "POST": CONF.write_text(json.dumps(request.get_json(), indent=2)) return jsonify({"ok": True, "restart_required": True}) return jsonify(json.loads(CONF.read_text())) def main() -> None: cfg = json.loads(CONF.read_text()) for f in cfg["feeds"]: feed = Feed(**f) threading.Thread(target=poll, args=(feed,), daemon=True).start() app.run(host="0.0.0.0", port=8080, threaded=True) if __name__ == "__main__": main()One thread per feedFeeds have wildly different natural rates — weather every 15 minutes, transit every 60 seconds, calendar every 5 minutes. Independent threads mean a slow or failing feed cannot delay the others.cache_get on exceptionThe failure path serves the last good value and marks it stale with an age. A dashboard showing 40-minute-old weather is useful; one showing an error is not.Server-Sent Events rather than pollingSSE is one long-lived HTTP response the server writes into. It is far simpler than WebSockets for one-way data, reconnects automatically in every browser, and means the page never polls.json.dumps comparison before yieldOnly push when something actually changed. Pushing every second forces a re-render, and on a Pi that is wasted CPU and, at night, a visible flicker.Control presence and brightness
This is the part that makes it feel finished rather than like a monitor with a mirror stuck on it.
pythonpresence.py#!/usr/bin/env python3 """Presence-gated display blanking and ambient brightness control.""" import math import subprocess import time import serial import smbus2 I2C_BUS, BH1750_ADDR = 1, 0x23 PRESENT_HOLD_S = 120 B_MIN, B_MAX = 8, 100 LUX_MAX, LAMBDA = 2000.0, 10.0 bus = smbus2.SMBus(I2C_BUS) ld2410 = serial.Serial("/dev/serial0", 256000, timeout=0.2) def read_lux() -> float: bus.write_byte(BH1750_ADDR, 0x10) # continuous high-res mode time.sleep(0.15) hi, lo = bus.read_i2c_block_data(BH1750_ADDR, 0x00, 2) return ((hi << 8) | lo) / 1.2 def read_presence() -> bool: """LD2410 engineering frames start F4 F3 F2 F1 and end F8 F7 F6 F5.""" data = ld2410.read(64) idx = data.find(b"\xf4\xf3\xf2\xf1") if idx < 0 or len(data) < idx + 12: return False target_state = data[idx + 8] return target_state in (1, 2, 3) # moving, stationary, or both def brightness_for(lux: float) -> int: lux = max(0.0, min(lux, LUX_MAX)) frac = math.log10(1 + lux / LAMBDA) / math.log10(1 + LUX_MAX / LAMBDA) return int(B_MIN + (B_MAX - B_MIN) * frac) def set_backlight(pct: int) -> None: # DDC/CI keeps full contrast, unlike gamma dimming. subprocess.run(["ddcutil", "setvcp", "10", str(pct)], check=False, capture_output=True) def set_display(on: bool) -> None: subprocess.run(["wlr-randr", "--output", "HDMI-A-1", "--on" if on else "--off"], check=False, capture_output=True) def main() -> None: last_seen = 0.0 display_on = True current_b = B_MAX set_display(True) while True: if read_presence(): last_seen = time.time() should_be_on = (time.time() - last_seen) < PRESENT_HOLD_S if should_be_on != display_on: display_on = should_be_on set_display(display_on) if display_on: want = brightness_for(read_lux()) # Move at most 5 points per second so it is never a visible jump. if abs(want - current_b) > 1: current_b += 5 if want > current_b else -5 current_b = max(B_MIN, min(B_MAX, current_b)) set_backlight(current_b) time.sleep(1.0) if __name__ == "__main__": main()target_state in (1, 2, 3)The LD2410 distinguishes moving targets, stationary targets, and both. Accepting stationary is the entire reason for using mmWave over a PIR — a person standing still at a mirror is exactly the case a PIR fails.ddcutil setvcp 10VCP code 0x10 is the standard DDC/CI brightness control. It adjusts the actual backlight, preserving full contrast — unlike gamma dimming, which crushes the black level you depend on for the mirror effect.brightness moves 5 points per secondA step change in backlight is very visible in a dark room. Ramping makes it imperceptible, which is the difference between a device that feels considered and one that feels twitchy.PRESENT_HOLD_S = 120Two minutes of grace after the last detection. Shorter and the display blanks while you are looking at it during a lull in detection; longer and the mirror is a screen for too much of the day.
Complete Source Code
The listing below is complete and compiles as written — there are no elided sections. Read the annotations under each block before you upload it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mirror</title>
<style>
/* Pure black everywhere. Any non-black pixel becomes visible haze
through the two-way mirror, so the palette is deliberately tiny. */
:root{
--fg:#ffffff; --dim:#8a8a8a; --faint:#4a4a4a;
--gap:2.4rem;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;background:#000;color:var(--fg);overflow:hidden;
font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-weight:200;
-webkit-font-smoothing:antialiased;cursor:none}
#grid{height:100%;display:grid;padding:var(--gap);gap:var(--gap);
grid-template-columns:1fr 1fr;grid-template-rows:auto 1fr auto}
.clock{grid-column:1/-1;text-align:center}
.clock .time{font-size:8rem;line-height:1;font-weight:100;letter-spacing:-.03em}
.clock .date{font-size:1.6rem;color:var(--dim);margin-top:.4rem;letter-spacing:.06em}
.panel h2{font-size:.85rem;letter-spacing:.28em;text-transform:uppercase;
color:var(--faint);margin-bottom:.9rem;font-weight:400}
.wx{display:flex;align-items:baseline;gap:1.2rem}
.wx .now{font-size:4.5rem;font-weight:100;line-height:1}
.wx .meta{font-size:1.15rem;color:var(--dim);line-height:1.6}
.fc{display:flex;gap:2rem;margin-top:1.2rem;font-size:1.05rem;color:var(--dim)}
.fc div{text-align:center}
.fc .d{color:var(--faint);font-size:.85rem;letter-spacing:.1em}
.cal li,.dep li{list-style:none;font-size:1.3rem;padding:.45rem 0;
display:flex;justify-content:space-between;gap:1.4rem;
border-bottom:1px solid #141414}
.cal li:last-child,.dep li:last-child{border-bottom:0}
.cal .when,.dep .in{color:var(--dim);font-variant-numeric:tabular-nums;white-space:nowrap}
.dep .soon{color:#fff;font-weight:400}
.footer{grid-column:1/-1;display:flex;justify-content:space-between;
align-items:flex-end;font-size:1rem;color:var(--faint)}
.stale{color:#9a7a2a}
/* Fade the whole page rather than cutting the backlight abruptly. */
body{transition:opacity 1.2s ease}
body.away{opacity:0}
</style>
</head>
<body>
<div id="grid">
<div class="clock">
<div class="time" id="time">--:--</div>
<div class="date" id="date"></div>
</div>
<section class="panel">
<h2>Weather</h2>
<div class="wx">
<div class="now" id="wxTemp">--°</div>
<div class="meta">
<div id="wxDesc">—</div>
<div id="wxRange">—</div>
<div id="wxWind">—</div>
</div>
</div>
<div class="fc" id="wxForecast"></div>
</section>
<section class="panel">
<h2>Today</h2>
<ul class="cal" id="calList"><li><span>No events</span></li></ul>
<h2 style="margin-top:2rem">Departures</h2>
<ul class="dep" id="depList"><li><span>—</span></li></ul>
</section>
<div class="footer">
<span id="reminders"></span>
<span id="status"></span>
</div>
</div>
<script>
const WX_CODE = {
0:'Clear', 1:'Mainly clear', 2:'Partly cloudy', 3:'Overcast',
45:'Fog', 48:'Rime fog', 51:'Light drizzle', 61:'Light rain',
63:'Rain', 65:'Heavy rain', 71:'Snow', 80:'Showers', 95:'Thunderstorm'
};
function tick() {
const d = new Date();
document.getElementById('time').textContent =
d.toLocaleTimeString('en-GB', {hour:'2-digit', minute:'2-digit'});
document.getElementById('date').textContent =
d.toLocaleDateString('en-GB', {weekday:'long', day:'numeric', month:'long'});
}
setInterval(tick, 1000); tick();
function renderWeather(w, stale, ageMin) {
if (!w) return;
document.getElementById('wxTemp').textContent = w.temp + '°';
document.getElementById('wxDesc').textContent = WX_CODE[w.code] || '—';
document.getElementById('wxRange').textContent = w.today_min + '° / ' + w.today_max + '°';
document.getElementById('wxWind').textContent = w.wind + ' km/h wind';
document.getElementById('wxForecast').innerHTML = (w.forecast || []).map(f =>
'<div><div class="d">' +
new Date(f.day).toLocaleDateString('en-GB',{weekday:'short'}) +
'</div>' + f.max + '° <span style="color:var(--faint)">' + f.min + '°</span></div>'
).join('');
}
function renderCalendar(items) {
const el = document.getElementById('calList');
if (!items || !items.length) { el.innerHTML = '<li><span>Nothing scheduled</span></li>'; return; }
el.innerHTML = items.slice(0, 5).map(e =>
'<li><span>' + e.title + '</span><span class="when">' + e.when + '</span></li>'
).join('');
}
function renderDepartures(items) {
const el = document.getElementById('depList');
if (!items || !items.length) { el.innerHTML = '<li><span>No departures</span></li>'; return; }
el.innerHTML = items.slice(0, 4).map(d =>
'<li><span>' + d.route + ' ' + d.dest + '</span>' +
'<span class="in ' + (d.mins <= 5 ? 'soon' : '') + '">' + d.mins + ' min</span></li>'
).join('');
}
/* Server-Sent Events: the server pushes, the page never polls. */
const es = new EventSource('/api/events');
es.onmessage = ev => {
const s = JSON.parse(ev.data);
const stale = [];
if (s.weather) { renderWeather(s.weather.data, s.weather.stale, s.weather.age_min);
if (s.weather.stale) stale.push('weather'); }
if (s.calendar) { renderCalendar(s.calendar.data);
if (s.calendar.stale) stale.push('calendar'); }
if (s.transit) { renderDepartures(s.transit.data);
if (s.transit.stale) stale.push('transit'); }
if (s.reminders) document.getElementById('reminders').textContent =
(s.reminders.data || []).join(' · ');
document.getElementById('status').innerHTML = stale.length
? '<span class="stale">stale: ' + stale.join(', ') + '</span>' : '';
};
es.onerror = () => {
document.getElementById('status').innerHTML =
'<span class="stale">daemon unreachable</span>';
};
/* Presence blanking is driven by a query parameter the daemon flips,
so the browser fades rather than the backlight cutting hard. */
const params = new URLSearchParams(location.search);
if (params.get('away') === '1') document.body.classList.add('away');
</script>
</body>
</html>
Configuration & Calibration
Configuration steps
- Choose the mirror transmission before anything else. 50/50 for a bright room where readability matters most; 70/30 for a bedroom or hallway where the mirror function matters most. You cannot have both.
- Set the panel backlight to maximum in its own OSD, then control brightness through DDC/CI. Reducing it in the OSD and then again in software wastes the range you need.
- Edit
mirror-config.jsonto define your feeds. Open-Meteo needs no API key and is a good starting weather source; transit APIs vary by city. - Set
PRESENT_HOLD_S. Two minutes suits a bathroom; a hallway wants thirty seconds so it spends most of the day as a mirror. - Enable a read-only root filesystem (
raspi-config→ Performance → Overlay File System) once the configuration is settled. A mirror gets power-cycled at the wall and SD-card corruption is the most common long-term failure.
Calibration procedure
An uncalibrated sensor produces confident, precise, wrong numbers. Do this once per physical unit and record the constants.
Test readability before you build the frame
Hold the mirror sheet loosely in front of the running display in the actual room, at the actual lighting, at the actual viewing distance. If the text is marginal now it will be worse once mounted. This five-minute test has saved many people a rebuild.
Set the brightness curve endpoints
Log lux at 3 a.m. and at the brightest point of a sunny morning. Set
LUX_MAXto your observed maximum andB_MINto the lowest backlight that is still readable in darkness — typically 6–10 %.Tune the mmWave sensitivity
The LD2410 has per-gate sensitivity settings covering 0.75 m increments. Reduce sensitivity on the far gates so it does not detect someone walking past a doorway three metres away, which otherwise keeps the mirror lit all day.
Check for ghosting
Any visible double image of the text means there is an air gap between the panel and the mirror. Press them together with a thin felt gasket; the gap should be effectively zero.
Network Architecture & Connectivity
Communication protocol
Everything is plain HTTP. The daemon fetches from upstream APIs on a schedule and pushes merged state to the browser over Server-Sent Events, which is a single long-lived HTTP response — no WebSocket library, no polling, and automatic browser-side reconnection.
API keys live only in mirror-config.json on the device. Nothing about your calendar or location leaves the mirror except the requests it makes directly to the providers you chose.
| Topic / endpoint | Direction | Payload |
|---|---|---|
GET /api/state | browser → daemon | Full merged state with per-feed staleness |
GET /api/events | daemon → browser (SSE) | Pushed state updates, only on change |
GET/POST /api/config | phone → daemon (LAN) | Feed definitions and layout |
Message contract between the device and the broker.
Security considerations
- Bind the config endpoint to the LAN only and put it behind basic authentication. It holds your API keys.
- Do not port-forward the mirror. If you need remote access, use a VPN.
- A mirror with a camera is a different project with different consent implications. This one deliberately has no camera, and if you add one, tell everyone who uses the room.
- Use a read-only root filesystem so a stolen or discarded SD card does not carry your credentials in a writable state — and encrypt the config file if the mirror is in a shared space.
Testing Procedure & Expected Output
Test from the bottom up. Confirm power, then each sensor in isolation, then the integrated loop — the first failing step tells you exactly where to look.
| Test | What you should see |
|---|---|
| Boot with no keyboard attached | Straight to full-screen dashboard within about 40 seconds, no desktop, no cursor. |
| Unplug the network | Data remains visible with a "stale: weather, transit" marker in the footer — never an error page or a blank panel. |
| Walk up and stand still for three minutes | Display comes on immediately and stays on. If it blanks while you are standing there, you are using a PIR rather than mmWave. |
| Leave the room | Display fades out about two minutes later and the surface reads as an ordinary mirror. |
| Turn the room lights off and on | Backlight ramps between levels over a few seconds with no visible step. |
| Look at the mirror off-axis in a bright room | Text still legible at 45°. If it washes out badly, the transmission ratio is too low for that room. |
| Pull the power without a shutdown, ten times | Boots normally every time. If not, enable the read-only overlay filesystem. |
| Check cavity temperature after two hours | Below 55 °C. Higher means the ventilation slots are inadequate and the Pi will throttle. |
Bench-test checklist. If a row fails, stop and fix it before moving on.
Expected output
With everything wired and the firmware uploaded, the Serial Monitor at 115200 baud should look similar to the trace below. Values will differ; the shape of the output should not.
Troubleshooting: Common Errors & Fixes
Performance Optimisation
- Use Server-Sent Events, not polling. A 1080p page re-rendering every five seconds keeps a Pi 4 busy for no benefit.
- Cache aggressively and poll each feed at its natural rate. Weather every 15 minutes is plenty; transit every 60 seconds is necessary. Polling everything at the fastest rate wastes API quota and CPU.
- Enable the GPU driver and hardware compositing. Software-composited full-screen rendering on a Pi is noticeably jerkier during transitions.
- Run the root filesystem read-only. It eliminates the most common long-term failure, and it makes boot faster.
- Pin the hot loop to one core with
tasksetand leave the others free for the OS. - Prefer MJPEG over raw YUY2 when capturing from USB cameras — the decode cost is far lower than the USB bandwidth cost.
- Log to a tmpfs RAM disk and flush to the SD card once a minute; per-sample SD writes are what kills cards.
- Run the service under
systemdwithRestart=alwaysso a crash never means a dead deployment. - Batch several samples into one MQTT publish. Radio time, not CPU time, dominates the energy budget.
- Set the MQTT keep-alive to a value that matches your reporting interval so the broker does not churn reconnections.
- Profile before optimising — print
micros()deltas around each stage and fix the slowest one first.
Safety Precautions
- Glass mirror is heavy and sharp. Two people for handling, gloves, and a frame rated for the weight — a 600 × 400 mm glass sheet plus a monitor is well over 8 kg on a wall fixing.
- Cutting acrylic produces fine dust and static; cut it slowly with a scoring tool rather than a fast saw, which melts the edge.
- A sealed cavity with a Pi and a driver board gets hot. Vent it, and never mount it directly against combustible cladding without an air gap.
- Do not fit a smart mirror in a shower enclosure. Even a bathroom install needs the electronics well outside the splash zone and the frame sealed at the top.
- Wear eye protection when soldering or cutting, and solder in a ventilated space — rosin flux fumes are a respiratory irritant.
- Power the circuit through a bench supply with a current limit while you are testing. A 300 mA limit turns a wiring mistake into a beep instead of a dead board.
- Disconnect power before changing any wiring. Hot-plugging a sensor onto a live bus is the fastest way to lose a controller.
Maintenance
- Re-check every screw terminal and header after the first week — thermal cycling loosens connections that felt tight on day one.
- Rotate the microSD card annually and keep an image of the working system. Cards used as loggers wear out silently.
- Keep the broker and dashboard containers patched, and rotate device credentials at least once a year.
- Recalibrate at the interval given in the calibration section, and keep the constants in a text file next to the firmware — not only in flash.
- Keep a short logbook of firmware versions and what changed. Six months later you will not remember why that constant is 1.083.
Future Improvements & Upgrades
A working v1 is a platform, not a finish line. These are the upgrades that add the most capability for the least rework.
- Add voice control using the offline wake-word engine from the voice hub project — a mirror is the natural place for it and it avoids adding a touchscreen.
- Add gesture control with a VL53L0X time-of-flight sensor: a hand wave at a fixed distance to page between screens, with no touch surface to smear.
- Add per-person profiles triggered by a phone's Bluetooth presence, so the mirror shows your calendar rather than the household one.
- Add a local weather station feed from the outdoor sensor in another project, which is far more relevant than a forecast for the nearest city.
- Move the UI to a static generator with no JavaScript framework — the whole interface here fits in one file, and keeping it that way is why it starts in under a second.
- Design a proper PCB. Once the breadboard version has run for a month, moving to a two-layer board removes the intermittent-contact failures that dominate prototype faults.
- Add over-the-air firmware updates so you never have to physically reach a deployed node again.
- Add persistent local storage (microSD or the on-chip flash) so a network outage does not create a hole in your data.
- Move configuration out of the source: a captive-portal setup page or a JSON config file makes the build reusable without a recompile.
- Add a battery and solar option so the unit survives a power cut and can be sited away from a socket.
- Write a small test harness that feeds synthetic sensor values through the decision logic, so you can validate thresholds without physically triggering the event.
Frequently Asked Questions
References & Learning Resources
These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.
- Two-way mirror physics and transmission ratiosWikipedia
- Raspberry Pi kiosk mode — official documentationRaspberry Pi Foundation
- HLK-LD2410 24 GHz human presence sensor — datasheet and protocolHi-Link
- DDC/CI and VESA MCCS — monitor control command setddcutil
- Server-Sent Events — MDN referenceMDN Web Docs
- Open-Meteo — free weather API with no key requiredOpen-Meteo
- MagicMirror² — the established open-source smart mirror platformMagicMirror²