Siddhant Kumar
Project 013 · Smart Home

Smart Mirror.

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.

Intermediate 14–20 hours 42 min read DisplayAPIUI
Jump to source Bill of materials
Smart Mirror — reference build illustration TEMPERATURE 27.4°
Difficulty
Intermediate
Build time
14–20 hours
Indicative cost
₹11,000 – ₹16,000
Platform
Raspberry Pi 4 Model B (4 GB)
Category
Smart Home
Last updated
28 July 2026
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.

A Raspberry Pi 4 Model B single-board computer viewed from the side
A Raspberry Pi single-board computer — the mirror controller, running Chromium in kiosk mode. Photograph sourced from Wikimedia Commons — Raspberry Pi 4 Model B - Side.jpg. Reused under the licence stated on that page; please check it before republishing.

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

SettingHow it is used
Bathroom or hallway morning dashboardThe information you actually want while getting ready, in a place you are already looking.
Entrance-hall departure boardLive transit times at the door remove the "should I run" question entirely.
Family calendar and choresA shared display in a shared space gets read; a shared calendar app does not.
Office receptionMeeting room status and visitor information without an obvious screen.
Gym or studioA mirror is already required; the workout timer and metrics come free.
Accessible information displayVery 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

AttributeValue
Difficulty levelIntermediate
Estimated completion time14–20 hours
Indicative build cost₹11,000 – ₹16,000
Primary disciplineSmart Home
Reference platformRaspberry 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.

ComponentKey specificationQtyApprox. 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 GPIO1₹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 eye1₹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 idle1₹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 recommended1₹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 protection1₹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 grid1₹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 transmission1₹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 removable1₹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 felt1₹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, UART1₹550
HDMI cable (right-angle) and short power leadsLow profile to fit the cavity depth1₹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

PartSpecificationSupplyInterfaceReference
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 GPIO5 V / 3 A USB-CGPIO, SPI, I²C, UART, CSI, DSIDatasheet
BH1750 digital ambient light sensor1–65535 lx, 16-bit, ±20 %, spectral response close to the human eye2.4–3.6 VI²C (0x23/0x5C)Datasheet
HC-SR501 PIR motion sensor3–7 m range, 110° cone, 0.3–200 s adjustable hold, 5 µA idle4.5–20 VDigital high on motionDatasheet
microSD card 32 GB A1 classA1 rated, 10 MB/s random write, UHS-I, endurance-grade recommended3.3 VSDIO / SPIDatasheet
5 V 3 A regulated SMPS adapter100–240 VAC in, 5 V ±5 % out, 3 A, short-circuit and over-voltage protection5 VDC barrel / USBDatasheet
Double-sided perfboard 7 × 9 cm + headersFR-4, 0.1″ pitch, plated through-holes, 24 × 18 gridDatasheet

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.

LoadSupply railTypical current (mA)Notes
Raspberry Pi 4 Model B (4 GB)5 V / 3 A USB-C1200Use an official 5 V 3 A supply — brown-outs from phone chargers corrupt SD cards.
BH1750 digital ambient light sensor2.4–3.6 V0.19Far more linear than an LDR — use it whenever you need real lux, not a relative value.
HC-SR501 PIR motion sensor4.5–20 V0.05Give it 60 s to settle after power-up or it fires false triggers.
microSD card 32 GB A1 class3.3 V100For 24/7 loggers buy a high-endurance card — normal cards die in months.
5 V 3 A regulated SMPS adapter5 V3000Measure 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-wide pip install by 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

LibraryWhy it is neededInstall
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.

Smart Mirror — system block diagramFunctional block diagram of the Smart Mirror system. InputLD2410 presencemmWave 24 GHzBH1750ambient luxConfig pagefrom phoneFetchPython daemonAPI pollingSQLite cacheoffline fallbackRenderChromium kioskblack-on-black UIBrightness mapperceptualShowLCD panelmax backlightTwo-way mirror50/50 or 70/30scheduleJSONrendered frame
Smart Mirror — system block diagram

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.

Smart Mirror — wiring schematicConnection schematic showing which controller pin drives each peripheral. Sensors / InputsControllerActuators / OutputsRaspberry Pi 4 ModelB (4 GB)5 V / 3 A USB-CLD2410 presence sensorGPIO 15 / 14UART 256000 baudBH1750 light sensorGPIO 2 / 3I²C at 0x23Optional PIR fallbackGPIO 17High on motionMonitor via HDMIHDMI0Display outputMonitor backlight controlSoftware brightnessStatus LED (build only)GPIO 27Remove before finalassembly
Smart Mirror — wiring schematic
PeripheralPeripheral pinController pinSignal
LD2410 presence sensorTX / RXGPIO 15 / 14UART 256000 baud
BH1750 light sensorSDA / SCLGPIO 2 / 3I²C at 0x23
Optional PIR fallbackOUTGPIO 17High on motion
Monitor via HDMIHDMIHDMI0Display output
Monitor backlight controlDDC/CI over HDMISoftware brightness
Status LED (build only)AnodeGPIO 27Remove 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.

Smart Mirror — architecture stackLayered architecture from hardware to user interface. Hardware layerRaspberry Pi 4 Model B (4 GB) · BH1750 digital ambient light sensor ·HC-SR501 PIR motion sensorDriver layerpython · flask · gpiozero · sqliteApplication logicsampling loop · filtering · thresholds · state machineTransport layerHTTP + SSE · TLS · retry and backoffPresentation layerdashboard · mobile notifications · historical charts
Smart Mirror — architecture stack

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

plainContrast 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

plainPerceptual 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.

Smart Mirror — firmware flowchartControl flow through the main program loop. Boot: start daemon, launchChromium kioskPoll APIs on their ownschedulesFetch succeeded?cache itserve cached + staleness flagRead presence sensor and luxPerson present in the last2 min?display onfade to blackMap lux to backlight, applysmoothlyPush updated JSON to the pageover SSELoop
Smart Mirror — firmware flowchart

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.

  1. 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.
  2. 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.
  3. 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.

htmlindex.html
<!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>
Pure #000 background, no imagesThe single most important CSS decision. A black pixel emits nothing, so that area of the panel reads as ordinary mirror. Any background colour, gradient or photograph becomes a visible grey rectangle.
font-weight 100–200 at large sizesCounter-intuitive but correct: through a mirror, thin strokes at large size read better than bold at small size. Bold text at small size blooms and loses definition.
--faint #4a4a4a for labelsSection labels are deliberately dim. They are context, not information — keeping them near the visibility floor stops them competing with the data and keeps more of the surface reading as mirror.
EventSource rather than setInterval fetchOne long-lived connection, automatic reconnection, and updates only when something changed. On a Pi driving a 1080p panel, avoiding a full re-render every few seconds is a measurable CPU saving.
body.away opacity transitionFading the page over 1.2 s is far less jarring than cutting the display output, and it means the mirror appears to gently return to being a mirror rather than snapping off.
tabular-nums on timesProportional digits make a countdown jitter horizontally as the numbers change. Tabular figures keep the column stable, which matters a lot on a display you glance at.

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.json to 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.

  1. 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.

  2. Set the brightness curve endpoints

    Log lux at 3 a.m. and at the brightest point of a sunny morning. Set LUX_MAX to your observed maximum and B_MIN to the lowest backlight that is still readable in darkness — typically 6–10 %.

  3. 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.

  4. 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

Smart Mirror — network topologyPath taken by telemetry from field node to end user. Edge nodesGatewayCloudClientsMirror (Pi 4)kiosk + daemonWi-Fi / EthernetRouterwired preferredHTTPS to APIsPublic APIsweather, transitPhone config pageLAN onlyHome Assistantoptional feed
Smart Mirror — network topology

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 / endpointDirectionPayload
GET /api/statebrowser → daemonFull merged state with per-feed staleness
GET /api/eventsdaemon → browser (SSE)Pushed state updates, only on change
GET/POST /api/configphone → 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.

TestWhat you should see
Boot with no keyboard attachedStraight to full-screen dashboard within about 40 seconds, no desktop, no cursor.
Unplug the networkData 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 minutesDisplay comes on immediately and stays on. If it blanks while you are standing there, you are using a PIR rather than mmWave.
Leave the roomDisplay fades out about two minutes later and the surface reads as an ordinary mirror.
Turn the room lights off and onBacklight ramps between levels over a few seconds with no visible step.
Look at the mirror off-axis in a bright roomText 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 timesBoots normally every time. If not, enable the read-only overlay filesystem.
Check cavity temperature after two hoursBelow 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.

A USB webcam
A small camera module. This build deliberately has none — adding one to a bathroom mirror is a consent question before it is a technical one. Photograph sourced from Wikimedia Commons — Webcam.jpg. Reused under the licence stated on that page; please check it before republishing.

Troubleshooting: Common Errors & Fixes

Text is barely readable through the mirror

Likely cause. Transmission ratio too low for the room brightness, panel backlight not at maximum, or a coloured background.

Fix. Confirm the background is pure #000 and the panel OSD brightness is at 100 %. If it is still marginal, the mirror is the problem: a 70/30 needs a very bright panel in a bright room. Swapping to 50/50 roughly doubles the effective contrast, at the cost of a slightly dimmer mirror.

A visible ghost image offset from the text

Likely cause. An air gap between the panel and the mirror creating a second reflection.

Fix. Clamp the panel directly against the mirror with a thin felt gasket. Even a 3 mm gap produces a clearly visible double image at normal viewing distance.

Grey patches visible in the mirror

Likely cause. Reflective surfaces inside the cavity — bare timber, screw heads, cable sheaths, the panel bezel.

Fix. Line the entire cavity in matte black felt and remove the bezel completely. Anything that reflects light back through the mirror will show.

The display blanks while someone is standing at it

Likely cause. A PIR sensor, which stops detecting a stationary person.

Fix. Replace with an LD2410 or similar mmWave module. This is the specific failure PIR cannot solve, and no amount of hold-time tuning fixes it without leaving the display on permanently.

The mmWave sensor detects people in the next room

Likely cause. 24 GHz passes through plasterboard easily, and the default sensitivity is high on all range gates.

Fix. Use the LD2410 configuration tool to lower the sensitivity of gates beyond your intended range, and set the maximum detection distance to just past where you actually stand.

Chromium shows a "restore pages" bar after a power cut

Likely cause. Unclean shutdown flags in the browser profile.

Fix. Add --disable-session-crashed-bubble --disable-infobars to the launch flags and clear the exit-type flag in the Preferences file on each boot via the systemd unit's ExecStartPre.

The Python script crashes with "externally-managed-environment" on pip install

Likely cause. Raspberry Pi OS Bookworm marks the system Python as managed by apt, and refuses global pip installs.

Fix. Create and activate a virtual environment — python3 -m venv ~/venv && source ~/venv/bin/activate — and install there. Use --system-site-packages if you also need apt-installed modules such as picamera2.

The Pi reboots or shows a lightning-bolt icon under load

Likely cause. Under-voltage. The supply sags below 4.63 V when the CPU and peripherals ramp up.

Fix. Use the official supply for your model (5 V 3 A for Pi 4, 5 V 5 A for Pi 5) and a short, thick USB-C cable. Check with vcgencmd get_throttled — anything other than 0x0 means power problems.

An I²C device is not detected

Likely cause. Wrong address, missing pull-ups, swapped SDA/SCL, or a bus too long for the pull-up value.

Fix. Run an I²C scanner sketch first — it should print the device address. Most breakout boards include 4.7 kΩ pull-ups, but if you have chained four of them the parallel resistance is too low; remove the pull-ups from all but one board. Keep the bus under 30 cm at 100 kHz.

Wi-Fi connects but MQTT never does (state -2)

Likely cause. Wrong broker address or port, a firewall in the way, or the broker requiring credentials the sketch is not sending.

Fix. Test from a laptop on the same network first: mosquitto_sub -h <broker> -t "#" -v. If that works, the problem is on the device — check the IP literal, port 1883 (or 8883 for TLS), and that client.setServer() runs before connect(). PubSubClient state codes are documented in its header.

Readings arrive for a while and then stop

Likely cause. The Wi-Fi or MQTT session dropped and the sketch never reconnects, or the broker dropped the client on keep-alive timeout.

Fix. Never assume the link stays up. Check WiFi.status() and client.connected() at the top of every loop and reconnect with exponential backoff. Add a watchdog so a wedged network stack reboots the device instead of going silent.

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 taskset and 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 systemd with Restart=always so 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

Glass or acrylic two-way mirror?

Acrylic is lighter, safer, cheaper and easier to cut, but scratches easily and can bow over a large span. Glass is optically better and scratch-resistant but heavy, fragile and expensive to cut to size. For a first build under 600 mm, acrylic. For a permanent installation you will keep for a decade, glass.

What transmission ratio should I buy?

50/50 if readability is the priority or the room is bright; 70/30 if the mirror function is the priority and the room is dimmer. The contrast calculation in the equations section shows why: at 70/30 in a bright room the display contributes less light than the reflection does, and text washes out.

Can I use MagicMirror² instead of writing this?

Absolutely, and for many people it is the right call — it has a large module ecosystem and solves the same problem. The reason to write your own is that the whole interface here is one HTML file with no build step and no framework, which starts in under a second and is trivial to modify. Both approaches are legitimate.

Why not a cheap tablet behind the mirror?

You can, and it is a much simpler build. The problems are brightness (most tablets are dimmer than a monitor at the same nominal figure once you account for the panel size), the impossibility of removing the bezel, and no DDC/CI for backlight control. It works for a small mirror and struggles for a large one.

Does it need to be always on?

No, and it should not be. A mirror that is a screen all day is just a screen. Presence gating is what makes it read as a mirror for the 95 % of the time nobody is standing there, and it also roughly halves the power consumption.

How much does it cost to run?

A Pi 4 plus a 22-inch panel at full backlight is around 30–35 W while active. With presence gating and a typical household pattern that is perhaps two hours a day, so roughly 25 kWh a year — a few hundred rupees. Leaving it on continuously would be ten times that.

References & Learning Resources

These are the primary sources worth reading in full. Manufacturer datasheets always outrank forum posts when the two disagree.

  1. Two-way mirror physics and transmission ratiosWikipedia
  2. Raspberry Pi kiosk mode — official documentationRaspberry Pi Foundation
  3. HLK-LD2410 24 GHz human presence sensor — datasheet and protocolHi-Link
  4. DDC/CI and VESA MCCS — monitor control command setddcutil
  5. Server-Sent Events — MDN referenceMDN Web Docs
  6. Open-Meteo — free weather API with no key requiredOpen-Meteo
  7. MagicMirror² — the established open-source smart mirror platformMagicMirror²