Scope: a real home-automation setup running on a small Kubernetes cluster and a Node.js service — Shelly relays turned into a sunset lighting scene, and a Prometheus alerting layer that watches doors, a basement water sensor, a submersible pump, and a central-heating circuit for the failure modes that actually matter (not just “sensor value crossed a threshold”). Every rule and code path below is the real, currently-deployed logic — this is meant as a concrete case study for anyone wiring up similar home IoT alerting, not a generic “how to IoT” tutorial.
1. The moving parts
- Shelly relays (Gen1, local HTTP API) — control lighting circuits around the house.
- A weather API poll — fetches current conditions and, critically, today’s sunrise/sunset timestamps, city-level (no home address or GPS coordinates involved).
- Mosquitto (MQTT) — the message bus every device and script publishes to/subscribes from, running as a StatefulSet in-cluster.
- NodeMCU sensor nodes — digital inputs on doors/gate/water sensors, temperature probes, feeding Prometheus metrics like
nodemcu_input1{node="garage-door"}. - Salus smart thermostats — per-floor heating setpoint/current-temperature, exposed as
salus_current_temperature/salus_heating_status. - A Shelly power-monitoring plug on the boiler (“centrala”) — the ground truth for whether the heating pump is actually drawing power, independent of what the thermostat claims.
- Prometheus + Alertmanager — evaluates the whole rule set every 5 seconds.
2. Shelly relays: direct local control, no cloud dependency
Lighting is controlled by hitting each Shelly’s own local HTTP API directly — no Shelly Cloud, no third-party hub in the loop:
GET http://<device-ip>/relay/0?turn=on
GET http://<device-ip>/relay/0?turn=offEight relays are configured, each just an IP + relay index + friendly name (device list trimmed/genericized below — the real list is longer and includes a couple of seasonal circuits like an X-Mas tree relay):
const DEVICES = [
{ ip: '192.168.1.35', relay: 0, name: 'Seasonal Lights' },
{ ip: '192.168.1.85', relay: 0, name: 'Bedroom Lights' },
{ ip: '192.168.1.86', relay: 0, name: 'Kids Room Lights' },
{ ip: '192.168.1.168', relay: 0, name: 'Office Lights' },
{ ip: '192.168.1.170', relay: 0, name: 'Living Room Lights' },
// ...
];Devices are triggered sequentially, not in parallel — a 300ms delay between each and a 1-second timeout per device — deliberately, so eight near-simultaneous LAN requests don’t collide with each other or with the relay’s own web server, which on Gen1 Shelly firmware is not built for concurrent hits. Every response is checked for a real ison field (not just HTTP 200 — a Shelly can answer with a malformed body), and failures are bucketed by cause (timeout / connection-refused / no-response / other) so the logs actually say what went wrong instead of a generic “failed”.
3. Sunset/sunrise: driven by real astronomical data, not a fixed clock
A weather API is polled every 30 seconds for current conditions; the response includes today’s sunrise/sunset as UTC unix timestamps, which is the actual trigger source — not a hardcoded “turn on lights at 19:00” schedule that would be wrong by over an hour across the year:
const API_URL =
'https://api.openweathermap.org/data/2.5/weather?q=<city>&units=metric&lang=en&APPID=<API_KEY>';
outsideApiData = {
utc_seconds: ...,
tC: info.main.temp,
sunrise: info.sys.sunrise, // unix timestamp UTC
sunset: info.sys.sunset, // unix timestamp UTC
};A separate loop, also on a 30-second tick, compares “now” against today’s sunset time. If the difference is positive and under a 2-minute trigger window, and the lights haven’t already been triggered today (tracked by comparing the date of the last trigger to today’s date, so a restart mid-day can’t double-fire), it turns every configured relay on:
const CONFIG = {
CHECK_INTERVAL_MS: 30 * 1000,
SUNSET_TRIGGER_DELTA_MINUTES: 2,
SUNRISE_TRIGGER_DELTA_MINUTES: 2
};
function shouldTrigger(eventName, timeDiffMinutes, today) {
const triggerWindow = eventName === 'sunset'
? CONFIG.SUNSET_TRIGGER_DELTA_MINUTES
: CONFIG.SUNRISE_TRIGGER_DELTA_MINUTES;
if (timeDiffMinutes < 0 || timeDiffMinutes >= triggerWindow) return false;
const lastTrigger = ...;
return lastTriggerDate !== today; // once per calendar day
}A real, honest gotcha worth calling out: the code has a complete, symmetric sunrise-triggered “turn everything off” path — but it’s currently commented out in production:
// if (shouldTrigger('sunrise', sunriseDiff.timeDiffMinutes, today)){
// const results = await triggerEvent('sunrise', false, sunriseTimeStr, '🌄');
// ...
// }So today, this system only turns lights on at sunset automatically; turning them back off is handled some other way (manual switches, a separate schedule, or just left to the household). That’s the kind of detail that’s invisible from a dashboard or a README and only shows up by reading the actual running code — worth checking for in any automation you didn’t write yourself last week.
Every trigger — automatic or manually forced via an admin endpoint — publishes a retained MQTT message with per-device success/failure counts, so “did the sunset scene actually run, and did every light respond” is answerable after the fact without re-triggering anything:
mqttClient.publish('shelly/scene/sunset/auto_triggered', JSON.stringify({
timestamp, deviceCount, results, sunsetTime
}), { retain: true });4. MQTT as the shared backbone
Mosquitto runs as a single-replica StatefulSet with password-file auth and a topic ACL — an admin user gets full read/write on #, everyone else is scoped to their own namespace plus shared sensor/#/device/#/home/# trees. It’s exposed via hostPort (not just a ClusterIP Service) specifically so IoT devices keep talking to the same LAN IP:port regardless of which node the pod lands on or whether the ingress controller is healthy — a device that already reconnects badly on its own shouldn’t also depend on cluster ingress being up.
A small Node-RED flow shows the same bus in use for something simpler: a 5-second cron pulls the current temperature via the weather API, republishes it to /temp/<user> over MQTT, and a subscriber formats it onto a small LCD display ({"line1": "<prefix>-" + temp}) — a good minimal example of “one value, one MQTT hop, one dumb display”, no Home Assistant or LCD-driver library required for something this small.
5. Safety alerts — the part that actually matters
All of it is one PrometheusRule, evaluated every 5 seconds, 29 rules covering the house. The pattern worth stealing across all of them: most alerts don’t just threshold one metric — they correlate two independent signals to cut false positives, and many escalate severity based on time of day.
5.1 Water level (basement flood risk)
- alert: Water Level
expr: min_over_time(nodemcu_input1{node="basement"}[5m]) == 1
for: 5d
labels: {severity: warning}
annotations: {summary: Low Water Level}
- alert: Water Level
expr: min_over_time(nodemcu_input2{node="basement"}[2m]) == 1
for: 30s
labels: {severity: critical}
annotations: {summary: High Water Level}Two separate float-switch inputs on the same sensor node, two very different response times: the “low” sensor (a slow seep/leak) only fires after it’s been true continuously for 5 days — deliberately slow, because a momentary damp reading isn’t an emergency. The “high” sensor (actual rising water) fires within 30 seconds — because that one genuinely can’t wait.
5.2 Freeze risk — API weather cross-checked against a real sensor
- alert: Water Near Freeze
expr: outside_temperature{} < 3
for: 5m
labels: {severity: critical}
- alert: Water Near Freeze
expr: nodemcu_temperature{node="outside mfy"} < 3 or nodemcu_temperature{node="outside mby"} < 3
for: 5m
labels: {severity: critical}Both the weather-API reading and two physical outdoor temperature probes independently trigger the same class of alert — a weather API can be wrong for a specific microclimate (a probe near an exposed pipe run can read colder than the city average), so both are watched rather than trusting either alone.
5.3 Doors and gate — correlated with context, not just "open == alert"
- alert: Garage is OPEN
expr: nodemcu_temperature{node="garage"} < 15
and on() nodemcu_input1{node="garage-door"} == 1
for: 5m
labels: {severity: warning}
- alert: Frontyard Door is OPEN
expr: nodemcu_input1{node="frontyard-door"} == 1
and on() ((hour(vector(time())) >= 17 and hour(vector(time())) <= 23)
or (hour(vector(time())) >= 0 and hour(vector(time())) <= 5))
for: 15s
labels: {severity: critical}Two techniques stacked on top of a plain door-open reading:
- Cross-referencing a second signal — the garage-open alert only fires if the garage is also measurably colder than the house, avoiding false positives from a flaky reed switch that reads "open" for a second during a bump.
- Time-of-day-aware severity — the exact same door-open condition is a low-priority 5-minute-tolerance
warningduring the day (people come and go), and a 15-second, near-instantcriticalovernight (17:00–05:00). Gate and backyard door use the identical pattern with their own tolerances.
5.4 Central heating — three distinct, independently-detectable failure modes
This is the most interesting group: three different ways "the heating system is wrong" can happen, each needs a different signal to catch, and none of them trust the thermostat's own self-reported state alone — they cross-check it against the boiler's actual power draw from a Shelly plug:
near# 1: thermostat says heating is ON, but temperature isn't rising
- alert: Heating not effective
expr: salus_current_temperature < salus_desired_temperature offset 15m - 0.4
and delta(salus_current_temperature[15m]) <= 0
and avg_over_time(salus_heating_status[15m]) == 1
for: 30m
# 2: thermostat says heating is ON, but the boiler ISN'T actually drawing power
- alert: Heating not working
expr: avg(shelly_power{node="centrala"}) < 50
and avg(last_over_time(salus_heating_status[10s])) > 0
for: 4m
labels: {severity: critical}
# 3: the inverse — boiler IS drawing power, but nothing asked it to
- alert: Heating is working even Termostat is Off
expr: avg(shelly_power{node="centrala"}) > 150
and max(max_over_time(salus_heating_status[1m])) == 0
for: 10m
labels: {severity: critical}Rule #2 is the one that actually catches a dead pump or a stuck valve: the thermostat can happily report "heating: ON" while the physical pump has failed — its opinion of its own state is not evidence the heating is real. Watching the boiler's power draw (measured completely independently, via a cheap power-monitoring smart plug, not anything heating-system-aware) is what makes that distinction possible. Rule #3 catches the opposite fault — a stuck relay running the pump with no demand, which quietly wastes energy and isn't something a thermostat can ever self-report, since from its point of view nothing is wrong.
A fourth rule uses the exact same boiler-power signal for something unrelated to space heating — detecting a hot water tap left running:
- alert: Hot water is running for long time
expr: avg(shelly_power{node="centrala"}) < 150 and min(shelly_power{node="centrala"}) > 50
for: 30m5.5 Submersible pump — stuck-on vs. suspiciously-cyclic
# Ran continuously too long
- alert: Submersible Pump still Running
expr: min_over_time(nodemcu_input1{node="submersible"}[1m]) == 1
for: 10m
labels: {severity: critical}
# Cycling on and off outside of expected irrigation windows
- alert: Submersible Pump intermitently Running
expr: avg_over_time(nodemcu_input1{node="submersible"}[10m]) > 0
unless on() (
(hour(vector(time())) == 2) or (hour(vector(time())) == 3) or ...
)
for: 30mThe second rule is the more interesting one: intermittent on/off cycling of a submersible pump usually means a stuck or partially-open valve somewhere, not a real demand for water — but only outside a set of known-expected irrigation hours, which are explicitly excluded with a PromQL unless clause rather than just widening the alert window and eating the false positives.
6. The dashboard
Grafana's heywesty-trafficlight-panel plugin renders the door/gate/water sensors as literal red/green traffic-light indicators — the fastest possible "is anything open right now" glance:

Below that row: per-floor thermostat gauges (current temperature vs. setpoint), the boiler's live power draw, and the water-level percentage graph — the same signals the alert rules above are evaluating, just human-readable:

The dashboard also carries a UPS section (battery charge, load, input/output voltage, estimated time-to-empty on battery) — worth a mention since it's the same "don't trust the device's own status LED, watch the actual electrical signal" philosophy applied to backup power instead of heating.
7. What makes this a good case study
- Real astronomical data beats a fixed clock for anything "at dusk" — a hardcoded time is wrong by well over an hour across the seasons.
- Cross-checking independent signals (a thermostat's self-reported state vs. a completely separate power-draw measurement; a door sensor vs. a temperature delta) catches failure classes that trusting either signal alone cannot — a flaky sensor and a truly failed system look identical if you only ever ask one question.
- Time-of-day-aware severity turns one PromQL expression into two useful alerts instead of picking one tolerance that's either too noisy by day or too slow by night.
- Read the actual running code, not just the dashboard — the disabled sunrise "lights off" trigger is a perfect example of behavior that's completely invisible unless you go looking at the source, and it changes what you should actually expect the system to do.
Comments are closed.