{"id":188,"date":"2026-08-17T05:56:07","date_gmt":"2026-08-17T05:56:07","guid":{"rendered":"http:\/\/127.0.0.1\/en\/2026\/08\/17\/iot-home-monitoring-shelly-relays-sunrise-sunset-automation-safety-alerts\/"},"modified":"2026-08-17T05:56:07","modified_gmt":"2026-08-17T05:56:07","slug":"iot-home-monitoring-shelly-relays-sunrise-sunset-automation-safety-alerts","status":"publish","type":"post","link":"https:\/\/wp.radut.info\/ro\/2026\/08\/17\/iot-home-monitoring-shelly-relays-sunrise-sunset-automation-safety-alerts\/","title":{"rendered":"IoT Home Monitoring: Shelly Relays, Sunrise\/Sunset Automation &#038; Safety Alerts"},"content":{"rendered":"<p><strong>Scope:<\/strong> a real home-automation setup running on a small Kubernetes cluster and a Node.js service \u2014 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 &#8220;sensor value crossed a threshold&#8221;). Every rule and code path below is the real, currently-deployed logic \u2014 this is meant as a concrete case study for anyone wiring up similar home IoT alerting, not a generic &#8220;how to IoT&#8221; tutorial.<\/p>\n<h2>1. The moving parts<\/h2>\n<ul>\n<li><strong>Shelly relays<\/strong> (Gen1, local HTTP API) \u2014 control lighting circuits around the house.<\/li>\n<li><strong>A weather API poll<\/strong> \u2014 fetches current conditions and, critically, today&#8217;s sunrise\/sunset timestamps, city-level (no home address or GPS coordinates involved).<\/li>\n<li><strong>Mosquitto (MQTT)<\/strong> \u2014 the message bus every device and script publishes to\/subscribes from, running as a StatefulSet in-cluster.<\/li>\n<li><strong>NodeMCU sensor nodes<\/strong> \u2014 digital inputs on doors\/gate\/water sensors, temperature probes, feeding Prometheus metrics like <code>nodemcu_input1{node=\"garage-door\"}<\/code>.<\/li>\n<li><strong>Salus smart thermostats<\/strong> \u2014 per-floor heating setpoint\/current-temperature, exposed as <code>salus_current_temperature<\/code>\/<code>salus_heating_status<\/code>.<\/li>\n<li><strong>A Shelly power-monitoring plug on the boiler<\/strong> (&#8220;centrala&#8221;) \u2014 the ground truth for whether the heating pump is actually drawing power, independent of what the thermostat claims.<\/li>\n<li><strong>Prometheus + Alertmanager<\/strong> \u2014 evaluates the whole rule set every 5 seconds.<\/li>\n<\/ul>\n<h2>2. Shelly relays: direct local control, no cloud dependency<\/h2>\n<p>Lighting is controlled by hitting each Shelly&#8217;s own local HTTP API directly \u2014 no Shelly Cloud, no third-party hub in the loop:<\/p>\n<pre><code>GET http:\/\/&lt;device-ip&gt;\/relay\/0?turn=on\nGET http:\/\/&lt;device-ip&gt;\/relay\/0?turn=off<\/code><\/pre>\n<p>Eight relays are configured, each just an IP + relay index + friendly name (device list trimmed\/genericized below \u2014 the real list is longer and includes a couple of seasonal circuits like an X-Mas tree relay):<\/p>\n<pre><code>const DEVICES = [\n  { ip: '192.168.1.35',  relay: 0, name: 'Seasonal Lights' },\n  { ip: '192.168.1.85',  relay: 0, name: 'Bedroom Lights' },\n  { ip: '192.168.1.86',  relay: 0, name: 'Kids Room Lights' },\n  { ip: '192.168.1.168', relay: 0, name: 'Office Lights' },\n  { ip: '192.168.1.170', relay: 0, name: 'Living Room Lights' },\n  \/\/ ...\n];<\/code><\/pre>\n<p>Devices are triggered <strong>sequentially<\/strong>, not in parallel \u2014 a 300ms delay between each and a 1-second timeout per device \u2014 deliberately, so eight near-simultaneous LAN requests don&#8217;t collide with each other or with the relay&#8217;s own web server, which on Gen1 Shelly firmware is not built for concurrent hits. Every response is checked for a real <code>ison<\/code> field (not just HTTP 200 \u2014 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 &#8220;failed&#8221;.<\/p>\n<h2>3. Sunset\/sunrise: driven by real astronomical data, not a fixed clock<\/h2>\n<p>A weather API is polled every 30 seconds for current conditions; the response includes today&#8217;s sunrise\/sunset as UTC unix timestamps, which is the actual trigger source \u2014 not a hardcoded &#8220;turn on lights at 19:00&#8221; schedule that would be wrong by over an hour across the year:<\/p>\n<pre><code>const API_URL =\n  'https:\/\/api.openweathermap.org\/data\/2.5\/weather?q=&lt;city&gt;&units=metric&lang=en&APPID=&lt;API_KEY&gt;';\n\noutsideApiData = {\n  utc_seconds: ...,\n  tC: info.main.temp,\n  sunrise: info.sys.sunrise,  \/\/ unix timestamp UTC\n  sunset:  info.sys.sunset,   \/\/ unix timestamp UTC\n};<\/code><\/pre>\n<p>A separate loop, also on a 30-second tick, compares &#8220;now&#8221; against today&#8217;s sunset time. If the difference is <strong>positive and under a 2-minute trigger window<\/strong>, and the lights haven&#8217;t already been triggered today (tracked by comparing the date of the last trigger to today&#8217;s date, so a restart mid-day can&#8217;t double-fire), it turns every configured relay on:<\/p>\n<pre><code>const CONFIG = {\n  CHECK_INTERVAL_MS: 30 * 1000,\n  SUNSET_TRIGGER_DELTA_MINUTES: 2,\n  SUNRISE_TRIGGER_DELTA_MINUTES: 2\n};\n\nfunction shouldTrigger(eventName, timeDiffMinutes, today) {\n  const triggerWindow = eventName === 'sunset'\n    ? CONFIG.SUNSET_TRIGGER_DELTA_MINUTES\n    : CONFIG.SUNRISE_TRIGGER_DELTA_MINUTES;\n  if (timeDiffMinutes &lt; 0 || timeDiffMinutes &gt;= triggerWindow) return false;\n  const lastTrigger = ...;\n  return lastTriggerDate !== today;   \/\/ once per calendar day\n}<\/code><\/pre>\n<p><strong>A real, honest gotcha worth calling out:<\/strong> the code has a complete, symmetric sunrise-triggered &#8220;turn everything off&#8221; path \u2014 but it&#8217;s currently commented out in production:<\/p>\n<pre><code>\/\/ if (shouldTrigger('sunrise', sunriseDiff.timeDiffMinutes, today)){\n\/\/   const results = await triggerEvent('sunrise', false, sunriseTimeStr, '\ud83c\udf04');\n\/\/   ...\n\/\/ }<\/code><\/pre>\n<p>So today, this system only turns lights <em>on<\/em> at sunset automatically; turning them back off is handled some other way (manual switches, a separate schedule, or just left to the household). That&#8217;s the kind of detail that&#8217;s invisible from a dashboard or a README and only shows up by reading the actual running code \u2014 worth checking for in any automation you didn&#8217;t write yourself last week.<\/p>\n<p>Every trigger \u2014 automatic or manually forced via an admin endpoint \u2014 publishes a retained MQTT message with per-device success\/failure counts, so &#8220;did the sunset scene actually run, and did every light respond&#8221; is answerable after the fact without re-triggering anything:<\/p>\n<pre><code>mqttClient.publish('shelly\/scene\/sunset\/auto_triggered', JSON.stringify({\n  timestamp, deviceCount, results, sunsetTime\n}), { retain: true });<\/code><\/pre>\n<h2>4. MQTT as the shared backbone<\/h2>\n<p>Mosquitto runs as a single-replica StatefulSet with password-file auth and a topic ACL \u2014 an admin user gets full read\/write on <code>#<\/code>, everyone else is scoped to their own namespace plus shared <code>sensor\/#<\/code>\/<code>device\/#<\/code>\/<code>home\/#<\/code> trees. It&#8217;s exposed via <code>hostPort<\/code> (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 \u2014 a device that already reconnects badly on its own shouldn&#8217;t also depend on cluster ingress being up.<\/p>\n<p>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 <code>\/temp\/&lt;user&gt;<\/code> over MQTT, and a subscriber formats it onto a small LCD display (<code>{\"line1\": \"&lt;prefix&gt;-\" + temp}<\/code>) \u2014 a good minimal example of &#8220;one value, one MQTT hop, one dumb display&#8221;, no Home Assistant or LCD-driver library required for something this small.<\/p>\n<h2>5. Safety alerts \u2014 the part that actually matters<\/h2>\n<p>All of it is one <code>PrometheusRule<\/code>, evaluated every 5 seconds, 29 rules covering the house. The pattern worth stealing across all of them: <strong>most alerts don&#8217;t just threshold one metric \u2014 they correlate two independent signals<\/strong> to cut false positives, and many escalate severity based on time of day.<\/p>\n<h3>5.1 Water level (basement flood risk)<\/h3>\n<pre><code>- alert: Water Level\n  expr: min_over_time(nodemcu_input1{node=\"basement\"}[5m]) == 1\n  for: 5d\n  labels: {severity: warning}\n  annotations: {summary: Low Water Level}\n\n- alert: Water Level\n  expr: min_over_time(nodemcu_input2{node=\"basement\"}[2m]) == 1\n  for: 30s\n  labels: {severity: critical}\n  annotations: {summary: High Water Level}<\/code><\/pre>\n<p>Two separate float-switch inputs on the same sensor node, two very different response times: the &#8220;low&#8221; sensor (a slow seep\/leak) only fires after it&#8217;s been true continuously for <strong>5 days<\/strong> \u2014 deliberately slow, because a momentary damp reading isn&#8217;t an emergency. The &#8220;high&#8221; sensor (actual rising water) fires within 30 seconds \u2014 because that one genuinely can&#8217;t wait.<\/p>\n<h3>5.2 Freeze risk \u2014 API weather cross-checked against a real sensor<\/h3>\n<pre><code>- alert: Water Near Freeze\n  expr: outside_temperature{} < 3\n  for: 5m\n  labels: {severity: critical}\n\n- alert: Water Near Freeze\n  expr: nodemcu_temperature{node=\"outside mfy\"} < 3 or nodemcu_temperature{node=\"outside mby\"} < 3\n  for: 5m\n  labels: {severity: critical}<\/code><\/pre>\n<p>Both the weather-API reading <em>and<\/em> two physical outdoor temperature probes independently trigger the same class of alert \u2014 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.<\/p>\n<h3>5.3 Doors and gate \u2014 correlated with context, not just \"open == alert\"<\/h3>\n<pre><code>- alert: Garage is OPEN\n  expr: nodemcu_temperature{node=\"garage\"} < 15\n        and on() nodemcu_input1{node=\"garage-door\"} == 1\n  for: 5m\n  labels: {severity: warning}\n\n- alert: Frontyard Door is OPEN\n  expr: nodemcu_input1{node=\"frontyard-door\"} == 1\n        and on() ((hour(vector(time())) >= 17 and hour(vector(time())) <= 23)\n               or (hour(vector(time())) >= 0  and hour(vector(time())) <= 5))\n  for: 15s\n  labels: {severity: critical}<\/code><\/pre>\n<p>Two techniques stacked on top of a plain door-open reading:<\/p>\n<ul>\n<li><strong>Cross-referencing a second signal<\/strong> \u2014 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.<\/li>\n<li><strong>Time-of-day-aware severity<\/strong> \u2014 the exact same door-open condition is a low-priority 5-minute-tolerance <code>warning<\/code> during the day (people come and go), and a 15-second, near-instant <code>critical<\/code> overnight (17:00\u201305:00). Gate and backyard door use the identical pattern with their own tolerances.<\/li>\n<\/ul>\n<h3>5.4 Central heating \u2014 three distinct, independently-detectable failure modes<\/h3>\n<p>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 \u2014 they cross-check it against the boiler's actual power draw from a Shelly plug:<\/p>\n<pre><code>near# 1: thermostat says heating is ON, but temperature isn't rising\n- alert: Heating not effective\n  expr: salus_current_temperature < salus_desired_temperature offset 15m - 0.4\n        and delta(salus_current_temperature[15m]) <= 0\n        and avg_over_time(salus_heating_status[15m]) == 1\n  for: 30m\n\n# 2: thermostat says heating is ON, but the boiler ISN'T actually drawing power\n- alert: Heating not working\n  expr: avg(shelly_power{node=\"centrala\"}) < 50\n        and avg(last_over_time(salus_heating_status[10s])) > 0\n  for: 4m\n  labels: {severity: critical}\n\n# 3: the inverse \u2014 boiler IS drawing power, but nothing asked it to\n- alert: Heating is working even Termostat is Off\n  expr: avg(shelly_power{node=\"centrala\"}) > 150\n        and max(max_over_time(salus_heating_status[1m])) == 0\n  for: 10m\n  labels: {severity: critical}<\/code><\/pre>\n<p>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 \u2014 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 \u2014 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.<\/p>\n<p>A fourth rule uses the exact same boiler-power signal for something unrelated to space heating \u2014 detecting a hot water tap left running:<\/p>\n<pre><code>- alert: Hot water is running for long time\n  expr: avg(shelly_power{node=\"centrala\"}) < 150 and min(shelly_power{node=\"centrala\"}) > 50\n  for: 30m<\/code><\/pre>\n<h3>5.5 Submersible pump \u2014 stuck-on vs. suspiciously-cyclic<\/h3>\n<pre><code># Ran continuously too long\n- alert: Submersible Pump still Running\n  expr: min_over_time(nodemcu_input1{node=\"submersible\"}[1m]) == 1\n  for: 10m\n  labels: {severity: critical}\n\n# Cycling on and off outside of expected irrigation windows\n- alert: Submersible Pump intermitently Running\n  expr: avg_over_time(nodemcu_input1{node=\"submersible\"}[10m]) > 0\n        unless on() (\n          (hour(vector(time())) == 2) or (hour(vector(time())) == 3) or ...\n        )\n  for: 30m<\/code><\/pre>\n<p>The 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 \u2014 but only <em>outside<\/em> a set of known-expected irrigation hours, which are explicitly excluded with a PromQL <code>unless<\/code> clause rather than just widening the alert window and eating the false positives.<\/p>\n<h2>6. The dashboard<\/h2>\n<p>Grafana's <code>heywesty-trafficlight-panel<\/code> plugin renders the door\/gate\/water sensors as literal red\/green traffic-light indicators \u2014 the fastest possible \"is anything open right now\" glance:<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/storage.radut.info\/2026\/08\/iot-trafficlights.png\" alt=\"Traffic-light style status panels for Water Level, Garage Door, Frontyard Door, Backyard Door, and Gate \u2014 all green (closed\/normal)\" \/><\/p>\n<p>Below that row: per-floor thermostat gauges (current temperature vs. setpoint), the boiler's live power draw, and the water-level percentage graph \u2014 the same signals the alert rules above are evaluating, just human-readable:<\/p>\n<p><img decoding=\"async\" src=\"https:\/\/storage.radut.info\/2026\/08\/iot-heating-water.png\" alt=\"Central heating power gauge and graph, Salus heating on\/off state, and water level percentage over time\" \/><\/p>\n<p>The dashboard also carries a UPS section (battery charge, load, input\/output voltage, estimated time-to-empty on battery) \u2014 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.<\/p>\n<h2>7. What makes this a good case study<\/h2>\n<ul>\n<li><strong>Real astronomical data beats a fixed clock<\/strong> for anything \"at dusk\" \u2014 a hardcoded time is wrong by well over an hour across the seasons.<\/li>\n<li><strong>Cross-checking independent signals<\/strong> (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 \u2014 a flaky sensor and a truly failed system look identical if you only ever ask one question.<\/li>\n<li><strong>Time-of-day-aware severity<\/strong> 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.<\/li>\n<li><strong>Read the actual running code, not just the dashboard<\/strong> \u2014 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.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Scope: a real home-automation setup running on a small Kubernetes cluster and a Node.js service \u2014 Shelly relays turned into a sunset lighting scene, and&#8230;<\/p>\n<div class=\"more-link-wrapper\"><a class=\"more-link\" href=\"https:\/\/wp.radut.info\/ro\/2026\/08\/17\/iot-home-monitoring-shelly-relays-sunrise-sunset-automation-safety-alerts\/\">Continue reading<span class=\"screen-reader-text\">IoT Home Monitoring: Shelly Relays, Sunrise\/Sunset Automation &#038; Safety Alerts<\/span><\/a><\/div>","protected":false},"author":0,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"qubely_global_settings":"","qubely_interactions":"","_coblocks_attr":"","_coblocks_dimensions":"","_coblocks_responsive_height":"","_coblocks_accordion_ie_support":"","_uag_custom_page_level_css":"","footnotes":""},"categories":[25],"tags":[],"class_list":["post-188","post","type-post","status-publish","format-standard","hentry","category-iot","entry"],"qubely_featured_image_url":null,"qubely_author":{"display_name":"","author_link":"https:\/\/wp.radut.info\/ro\/author\/"},"qubely_comment":0,"qubely_category":"<a href=\"https:\/\/wp.radut.info\/ro\/category\/iot\/\" rel=\"category tag\">IoT<\/a>","qubely_excerpt":"Scope: a real home-automation setup running on a small Kubernetes cluster and a Node.js service \u2014 Shelly relays turned into a sunset lighting scene, and&#8230;Continue readingIoT Home Monitoring: Shelly Relays, Sunrise\/Sunset Automation &#038; Safety Alerts","uagb_featured_image_src":{"full":false,"thumbnail":false,"medium":false,"medium_large":false,"large":false,"1536x1536":false,"2048x2048":false,"trp-custom-language-flag":false,"qubely_landscape":false,"qubely_portrait":false,"qubely_thumbnail":false},"uagb_author_info":{"display_name":"","author_link":"https:\/\/wp.radut.info\/ro\/author\/"},"uagb_comment_info":0,"uagb_excerpt":"Scope: a real home-automation setup running on a small Kubernetes cluster and a Node.js service \u2014 Shelly relays turned into a sunset lighting scene, and&#8230;Continue readingIoT Home Monitoring: Shelly Relays, Sunrise\/Sunset Automation &#038; Safety Alerts","_links":{"self":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/posts\/188","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/comments?post=188"}],"version-history":[{"count":0,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/posts\/188\/revisions"}],"wp:attachment":[{"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/media?parent=188"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/categories?post=188"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wp.radut.info\/ro\/wp-json\/wp\/v2\/tags?post=188"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}