How I built a zero-cloud, fully autonomous smart bathroom using bare-metal Shelly Gen 4 devices running mJS—from reverse-engineering an illuminated vanity mirror to adaptive EMA humidity filters and mechanical pull-cord gestures.
The Problem with Smart Bathrooms
Smart home tech is great - but often overly reliant on a solid internet connection for phone home services. If your internet hiccups or your Home Assistant server reboots while you're in the living room, a lamp stays on an extra minute. In a bathroom, you're standing in the shower in pitch black, or listening to a fan roar at 3 AM because a cloud webhook timed out.
Worse, standard bathroom automations ignore physical realities:
- The dumb humidity trigger: Fixed thresholds (
if humidity > 70%) are useless in the UK. In a humid August, ambient air is naturally 75%—meaning the fan runs 24/7. In a dry January, a hot shower might only peak at 55%—leaving the fan completely dead. - The "smart" vanity mirror: Touchless LED mirrors use internal infrared break-beam sensors. If you cut mains power with a smart plug, it wipes the clock and defaults to
OFFwhen power returns. You still have to wave your hand. - The mechanical pull cord: Pull cords suffer from severe contact bounce and trailing tugs. Simple toggle automations end up with stuttering relays and crashed scripts.
I wanted a bathroom that just works. No cloud, no external broker, and zero dependency on Home Assistant for core operation. If the router dies or the server restarts, every switch, sensor, fan, and light had to keep running with sub-millisecond local latency.
The Setup
The bathroom runs across four Shelly Gen 4 / Plus nodes communicating over local LAN RPC, plus a countertop Bluetooth environmental sensor:
- Central Orchestrator (Shelly 2PM Gen 4): Controls downlights and the extractor fan. Reads the mechanical pull cord (
input:0) and an active 24V mmWave radar sensor (input:1) in detached mode. Acts as a Bluetooth gateway for the countertop sensor. - Vanity Mirror (Shelly 1 Gen 4): Tucked inside the mirror chassis to simulate hand waves and monitor physical power.
- Plinth Strip (Shelly Plus RGBW PM): Warm-white accent lighting used for night walks and pre-vacancy warning alerts.
- Towel Rail (Shelly 1PM Mini Gen 4): Thermostatically cycles the heating element (<16°C on, >=18°C off) with overnight quiet hours.
Hacking the Vanity Mirror (Closed-Loop IR)
To automate an illuminated mirror without losing its internal settings, I opened the aluminum carcass and tapped two internal lines:
- The 3-wire optical sensor: A low-voltage trigger line that drops low when a hand reflects the IR beam.
- The demister relay line: The switched power feed that energizes the anti-fog heating pad whenever the LEDs are lit.
I wired a Shelly 1 Gen 4 directly inside the mirror:
- Relay Output (
O/I): Dry contact wired in parallel across the optical sensor trigger line. Pulsing the relay for 120ms mimics a human hand waving underneath. - Switch Input (
SW): Tapped into the demister relay line to read actual, physical on/off status.
+-------------------------------------------------------+
| Vanity Mirror Carcass |
| [Shelly 1 Gen 4] |
| Relay O/I -----> Parallel to IR Optical Sensor |
| Input SW <----- Tapped from Demister Power Line |
+-------------------------------------------------------+Closed-Loop Feedback
A software toggle cannot blindly pulse an optical line, or it will invert manual hand waves. The mirror script (vanity_mirror_controller.js) compares actualState against targetState. If mismatched, it pulses the dry contact, waits 1 second, and verifies the demister line energized.
- Relay Wear Protection: If the mirror is isolated at the wall, it halts after 3 failed attempts instead of clicking endlessly.
- Decoupled Debounce: Physical hand waves use a 600ms debounce; software requests use 800ms on separate timers so manual waves don't cancel app commands.
- Safety Watchdog: Automatically pulses the mirror off after 60 continuous minutes to protect the demister heating pad.
The Central Brain: Smarter Sensing & Gestures
The master Shelly 2PM runs central_controller.js directly on bare-metal mJS.
1. Dynamic Humidity (EMA + Anti-Creep Lock)
Instead of a fixed percentage, the script calculates baseline room humidity using an Exponential Moving Average (α = 0.05):
EMA_t = α · RH_t + (1 - α) · EMA_{t-1}
A shower is flagged when relative humidity spikes >= 10% above baseline.
The Anti-Creep Trap: If the baseline updates while steam fills the room, the calculated baseline steadily rises. When the shower stops, the inflated baseline fools the fan into cutting out early. The script detects the spike and freezes the baseline, keeping the fan running until humidity drops back within
5%of the original pre-shower level.
2. Multi-Gesture Pull Cord Decoding
With the pull cord input detached from the physical relay, an internal state machine decodes pull gestures with a 150ms debounce:
- Single Pull: Immediately toggles the ceiling downlights.
- Double Pull (within 1200ms): Leaves the downlights in their switched state, toggles the extractor fan, and engages a 30-minute Quiet Block (preventing automatic humidity triggers while having a bath).
- Trailing Tugs: A 400ms lockout swallows cord vibrations and bounces.
3. mmWave Radar & Pre-Vacancy Warning
PIR sensors fail in showers because glass and steam block infrared light. A 24V active mmWave radar sensor mounted above the door easily penetrates the glass enclosure to detect micro-movements.
When an occupant leaves, the system enters a 10-second WARNING state: the plinth lights gently pulse. If motion is detected during the warning, lighting is instantly restored to its previous level with zero disruption; otherwise, everything shuts off cleanly.
Taming Embedded mJS
Shelly Gen 3/4 devices run mJS—a micro JavaScript engine built for microcontrollers. Writing 1,100 lines of mJS means navigating strict ESP32 resource caps:
- The 5-Timer Ceiling: Firmware caps scripts at 5 concurrent hardware timers. The controller runs a single recurring 1-second tick that multiplexes an arbitrary number of software timers by comparing timestamps.
- The 5-Job RPC Limit: Outbound network calls throw error
-108if more than 5 are pending. A bounded FIFORpcQueueserializes calls to peer Shellys (plinth, towel rail, mirror), keeping max in-flight RPCs to 1. - Missing Array Methods: mJS lacks
shift()andsplice(). Calling them crashes the script immediately. Dequeuing items must be done using immutable slicing:arr = arr.slice(1). - Zero Flash Wear: High-frequency telemetry lives in RAM. Only critical manual milestones (like quiet blocks) are committed to flash KVS.
Code & Deployment
Rather than cluttering the Shelly app with 15 Virtual Components, both controllers register a lightweight native HTTP endpoint (GET /script/1/status) that outputs complete state telemetry in JSON for easy scraping.
The entire project is open-source under the MIT license: 👉 github.com/jcktwd/shelly-bathroom
Because the central controller is ~44 KB, uploading it via the web UI buffer-overflows the device. The repo includes a chunked OTA PowerShell tool (deploy.ps1) that injects your local network IPs in memory from a gitignored deploy.config.json before uploading:
# Flash Central Controller
.\deploy.ps1 -Target central
# Flash Vanity Mirror Controller
.\deploy.ps1 -Target mirrorIf you're looking for a bathroom automation that survives real-world edge cases without relying on cloud services, feel free to grab the scripts and adapt them.