From 6627372ec0495b56873162f77768b605789266fd Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Fri, 3 Jul 2026 11:28:36 +0200 Subject: [PATCH] feat(monitoring): add prometheus metrics, alerts, and dashboard for taupi fan --- .../grafana-dashboard-taupi-fan.yaml | 266 ++++++++++++++++++ k8s/monitoring/taupi-fan.yml | 83 ++++++ terraform_shelly/scripts/Taupi-4.0.js | 139 ++++----- 3 files changed, 420 insertions(+), 68 deletions(-) create mode 100644 k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml create mode 100644 k8s/monitoring/taupi-fan.yml diff --git a/k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml b/k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml new file mode 100644 index 0000000..73b7a57 --- /dev/null +++ b/k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml @@ -0,0 +1,266 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-taupi-fan + namespace: monitoring + labels: + grafana_dashboard: '1' + annotations: + grafana_dashboard_folder: 🏠 Home +data: + taupi-fan.json: | + { + "uid": "taupi-fan", + "title": "💨 Cellar Fan (TAUPI)", + "tags": ["climate", "fan", "cellar", "prometheus"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "editable": false, + "graphTooltip": 1, + "time": { + "from": "now-24h", + "to": "now" + }, + "panels": [ + { + "title": "⚡ Status & Controls", + "type": "row", + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 0}, + "collapsed": false + }, + { + "title": "Fan Power Relay", + "type": "stat", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 4, "w": 6, "x": 0, "y": 1}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"type": "value", "options": {"0": {"text": "OFF", "color": "blue"}, "1": {"text": "ON", "color": "orange"}}} + ], + "color": {"mode": "thresholds"} + } + }, + "options": { + "graphMode": "none", + "colorMode": "background", + "textMode": "auto", + "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} + }, + "targets": [ + {"expr": "taupi_relay_status", "legendFormat": "Fan Status"} + ] + }, + { + "title": "Mold Danger Status", + "type": "stat", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 4, "w": 6, "x": 6, "y": 1}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"type": "value", "options": {"0": {"text": "SAFE", "color": "green"}, "1": {"text": "CRITICAL", "color": "red"}}} + ] + } + }, + "options": { + "graphMode": "none", + "colorMode": "background", + "textMode": "auto", + "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} + }, + "targets": [ + {"expr": "taupi_is_critical", "legendFormat": "Mold Danger"} + ] + }, + { + "title": "Activation Criteria Met", + "type": "stat", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 4, "w": 6, "x": 12, "y": 1}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"type": "value", "options": {"0": {"text": "NO", "color": "gray"}, "1": {"text": "YES", "color": "green"}}} + ] + } + }, + "options": { + "graphMode": "none", + "colorMode": "background", + "textMode": "auto", + "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} + }, + "targets": [ + {"expr": "taupi_would_fan_activate", "legendFormat": "Dewpoint Condition"} + ] + }, + { + "title": "Shelly Plug Reachability", + "type": "stat", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 4, "w": 6, "x": 18, "y": 1}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"type": "value", "options": {"0": {"text": "OFFLINE", "color": "red"}, "1": {"text": "ONLINE", "color": "green"}}} + ] + } + }, + "options": { + "graphMode": "none", + "colorMode": "background", + "textMode": "auto", + "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} + }, + "targets": [ + {"expr": "up{job=\"taupi-fan\"}", "legendFormat": "Connectivity"} + ] + }, + { + "title": "🌡️ Climate Details", + "type": "row", + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 5}, + "collapsed": false + }, + { + "title": "Temperatures", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 6}, + "fieldConfig": { + "defaults": { + "unit": "celsius", + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": true, + "showPoints": "never" + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_temperature_celsius_innen", "legendFormat": "Indoor Temperature"}, + {"expr": "taupi_temperature_celsius_aussen", "legendFormat": "Outdoor Temperature"} + ] + }, + { + "title": "Dew Points", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 6}, + "fieldConfig": { + "defaults": { + "unit": "celsius", + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": true, + "showPoints": "never" + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_dewpoint_celsius_innen", "legendFormat": "Indoor Dewpoint"}, + {"expr": "taupi_dewpoint_celsius_aussen", "legendFormat": "Outdoor Dewpoint"} + ] + }, + { + "title": "Relative Humidity vs. Mold Threshold", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 8, "w": 24, "x": 0, "y": 14}, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": true, + "showPoints": "never" + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity"}, + {"expr": "taupi_humidity_percent_aussen", "legendFormat": "Outdoor Humidity"}, + {"expr": "taupi_critical_humidity_threshold_percent", "legendFormat": "Mold Danger Threshold"} + ] + }, + { + "title": "🔋 BLE Sensors Diagnostic", + "type": "row", + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 22}, + "collapsed": false + }, + { + "title": "Sensor Batteries", + "type": "gauge", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 6, "w": 12, "x": 0, "y": 23}, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "orange", "value": 20}, + {"color": "green", "value": 50} + ] + } + } + }, + "options": { + "reduceOptions": {"values": false, "calcs": ["lastNotNull"]}, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + {"expr": "taupi_battery_percent_innen", "legendFormat": "Indoor Sensor Battery"}, + {"expr": "taupi_battery_percent_aussen", "legendFormat": "Outdoor Sensor Battery"} + ] + }, + { + "title": "Sensor Connection Age (Staleness)", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 6, "w": 12, "x": 12, "y": 23}, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": true, + "showPoints": "never" + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_lost_connection_seconds_innen", "legendFormat": "Indoor Connection Stale Time"}, + {"expr": "taupi_lost_connection_seconds_aussen", "legendFormat": "Outdoor Connection Stale Time"} + ] + } + ] + } diff --git a/k8s/monitoring/taupi-fan.yml b/k8s/monitoring/taupi-fan.yml new file mode 100644 index 0000000..345b20e --- /dev/null +++ b/k8s/monitoring/taupi-fan.yml @@ -0,0 +1,83 @@ +apiVersion: v1 +kind: Service +metadata: + name: taupi-fan + namespace: monitoring + labels: + app: taupi-fan +spec: + ports: + - name: http-metrics + port: 80 + targetPort: 80 +--- +apiVersion: v1 +kind: Endpoints +metadata: + name: taupi-fan + namespace: monitoring + labels: + app: taupi-fan +subsets: + - addresses: + - ip: 192.168.10.168 + ports: + - name: http-metrics + port: 80 +--- +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: taupi-fan + namespace: monitoring + labels: + app: taupi-fan + release: prometheus-operator +spec: + selector: + matchLabels: + app: taupi-fan + namespaceSelector: + matchNames: + - monitoring + endpoints: + - port: http-metrics + path: /script/1/prometheus + interval: 30s + scrapeTimeout: 10s +--- +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: taupi-fan-alerts + namespace: monitoring + labels: + release: prometheus-operator +spec: + groups: + - name: taupi-fan.rules + rules: + - alert: TaupiFanOffline + expr: up{job="taupi-fan"} == 0 + for: 5m + labels: + severity: critical + annotations: + summary: "Id: {{ $labels.instance }} - TAUPI Fan Plug is unreachable" + description: "The Shelly plug at 192.168.10.168 is unreachable by Prometheus. Check Wi-Fi connection or power status." + - alert: TaupiFanSensorsStale + expr: taupi_lost_connection_seconds_innen > 600 or taupi_lost_connection_seconds_aussen > 600 + for: 5m + labels: + severity: warning + annotations: + summary: "TAUPI Fan BLE sensors stale" + description: "The BLE sensors for temperature and humidity inside or outside have not sent new data for over 10 minutes." + - alert: TaupiFanMoldDanger + expr: taupi_is_critical == 1 + for: 30m + labels: + severity: warning + annotations: + summary: "Cellar mold risk detected" + description: "The cellar relative humidity has exceeded the critical mold threshold (inside humidity is high enough that mold can grow on cold wall surfaces)." diff --git a/terraform_shelly/scripts/Taupi-4.0.js b/terraform_shelly/scripts/Taupi-4.0.js index c62aa8f..0e67991 100644 --- a/terraform_shelly/scripts/Taupi-4.0.js +++ b/terraform_shelly/scripts/Taupi-4.0.js @@ -170,46 +170,20 @@ const uint8 = 0; const int8 = 1; const uint16 = 2; const int16 = 3; -const uint24 = 4; -const int24 = 5; -const dimmert = 6; // special data type for dimmer event -const uint32 = 7; -const int32 = 8; -// The BTH object defines the structure of the BTHome data +// The BTH object defines the structure of the BTHome data (trimmed to temperature/humidity/battery) const BTH = { 0x00: { n: "pid", t: uint8 }, - 0x01: { n: "battery", t: uint8, u: "%" }, - 0x02: { n: "temperature", t: int16, f: 0.01, u: "tC" }, - 0x04: { n: "atm. pressure", t: int24, f: 0.01, u: "hPa" }, - 0x03: { n: "humidity", t: uint16, f: 0.01, u: "%" }, - 0x05: { n: "illuminance", t: uint24, f: 0.01 }, - 0x08: { n: "dew point", t: uint16, f: 0.01, u: "tC" }, - 0x0C: { n: "capacitor voltage", t: uint16, f: 0.001, u: "V" }, - 0x1e: { n: "light", t: uint8 }, - 0x20: { n: "raining", t: uint8}, - 0x21: { n: "motion", t: uint8 }, - 0x2d: { n: "window", t: uint8 }, - 0x2e: { n: "humidity", t: uint8, u: "%" }, - 0x3a: { n: "button", t: uint8 }, - 0x3c: { n: "dimmer", t: dimmert }, - 0x3f: { n: "rotation", t: int16, f: 0.1 }, - 0x44: { n: "wind speed", t: int16, f: 0.01, u: "m/s" }, - 0x45: { n: "temperature", t: int16, f: 0.1, u: "tC" }, - 0x46: { n: "UV index", t: int8, f: 0.1 }, - 0x5E: { n: "wind direction", t: int16, f: 0.01 }, - 0x5F: { n: "precipitation", t: int16, f: 0.1, u: "mm" }, - 0x60: { n: "channel", t: uint8 }, - 0xf0: { n: "dev_type", t: uint16 }, - 0xf1: { n: "fw_version", t: uint32 }, - 0xf2: { n: "fw_version_short", t: uint24 }, + 0x01: { n: "battery", t: uint8 }, + 0x02: { n: "temperature", t: int16, f: 0.01 }, + 0x03: { n: "humidity", t: uint16, f: 0.01 }, + 0x2e: { n: "humidity", t: uint8 }, + 0x45: { n: "temperature", t: int16, f: 0.1 } }; function getByteSize(type) { if (type === uint8 || type === int8) return 1; - if (type === uint16 || type === int16 || type === dimmert) return 2; - if (type === uint24 || type === int24) return 3; - if (type === uint32 || type === int32) return 4; + if (type === uint16 || type === int16) return 2; return 255; } @@ -222,9 +196,6 @@ const BTHomeDecoder = { getUInt8: function (buffer) { return buffer.at(0); }, - getDimmer: function (buffer) { - return {"dimmer": buffer.at(0), "dimmersteps": buffer.at(1)}; - }, getInt8: function (buffer) { return this.utoi(this.getUInt8(buffer), 8); }, @@ -234,33 +205,13 @@ const BTHomeDecoder = { getInt16LE: function (buffer) { return this.utoi(this.getUInt16LE(buffer), 16); }, - getUInt24LE: function (buffer) { - return ( - 0x00ffffff & ((buffer.at(2) << 16) | (buffer.at(1) << 8) | buffer.at(0)) - ); - }, - getInt24LE: function (buffer) { - return this.utoi(this.getUInt24LE(buffer), 24); - }, - getUInt32LE: function (buffer) { - return 0xffffffff & ((buffer.at(3) << 24) | (buffer.at(2) << 16) | (buffer.at(1) << 8) | buffer.at(0)); - }, - getInt32LE: function (buffer) { - return this.utoi(this.getUInt32LE(buffer), 32); - }, getBufValue: function (type, buffer) { if (buffer.length < getByteSize(type)) return null; - let res = null; - if (type === uint8) res = this.getUInt8(buffer); - if (type === int8) res = this.getInt8(buffer); - if (type === uint16) res = this.getUInt16LE(buffer); - if (type === int16) res = this.getInt16LE(buffer); - if (type === uint24) res = this.getUInt24LE(buffer); - if (type === int24) res = this.getInt24LE(buffer); - if (type === uint32) res = this.getUInt32LE(buffer); - if (type === int32) res = this.getInt32LE(buffer); - if (type === dimmert) res = this.getDimmer(buffer); - return res; + if (type === uint8) return this.getUInt8(buffer); + if (type === int8) return this.getInt8(buffer); + if (type === uint16) return this.getUInt16LE(buffer); + if (type === int16) return this.getInt16LE(buffer); + return null; }, unpack: function (buffer) { @@ -278,18 +229,11 @@ const BTHomeDecoder = { while (buffer.length > 0) { _bth = BTH[buffer.at(0)]; if (typeof _bth === "undefined") { - print("BTH: Unknown type: ", buffer.at(0)); break; } buffer = buffer.slice(1); _value = this.getBufValue(_bth.t, buffer); - // handle dimmer special case - if (typeof _value === "object" && _bth.t === dimmert) { - result["dimmersteps"] = _value["dimmersteps"]; - _value = _value["dimmer"]; - } - if (_value === null) break; if (typeof _bth.f !== "undefined") _value = _value * _bth.f; @@ -378,3 +322,62 @@ function initBLE() { } initBLE(); + +// Expose metrics for Prometheus +if (typeof HTTPServer !== "undefined" && typeof HTTPServer.registerEndpoint === "function") { + HTTPServer.registerEndpoint("prometheus", function(req, res) { + var body = ""; + + function addGauge(name, val, help) { + if ((typeof val === "number" && !isNaN(val)) || typeof val === "boolean") { + var numVal = (typeof val === "boolean") ? (val ? 1 : 0) : val; + if (help) { + body += "# HELP " + name + " " + help + "\n"; + } + body += "# TYPE " + name + " gauge\n"; + body += name + " " + numVal + "\n"; + } + } + + // Calculate helper values + var rh_crit = (typeof temperatur_innen !== "undefined" && !isNaN(temperatur_innen)) ? (critical_humi_base - critical_humi_temp_coeff * (temperatur_innen - critical_humi_ref_temp)) : undefined; + var rh_activation = (typeof rh_crit !== "undefined" && !isNaN(rh_crit)) ? (rh_crit - critical_humi_buffer) : undefined; + var is_critical_val = (typeof humidity_innen !== "undefined" && !isNaN(humidity_innen) && typeof rh_activation !== "undefined" && !isNaN(rh_activation)) ? (humidity_innen >= rh_activation) : undefined; + + var would_fan_activate = false; + if (typeof is_critical_val !== "undefined" && is_critical_val !== null && + typeof temperatur_innen !== "undefined" && !isNaN(temperatur_innen) && + typeof humidity_innen !== "undefined" && !isNaN(humidity_innen) && + typeof taupunkt_innen !== "undefined" && !isNaN(taupunkt_innen) && + typeof taupunkt_aussen !== "undefined" && !isNaN(taupunkt_aussen)) { + would_fan_activate = is_critical_val && + (temperatur_innen > mindesttemperatur) && + (humidity_innen > mindesthumi) && + (taupunkt_innen > taupunkt_aussen + taupunktschwelle); + } + + addGauge("taupi_temperature_celsius_innen", temperatur_innen, "Indoor temperature in Celsius"); + addGauge("taupi_temperature_celsius_aussen", temperatur_aussen, "Outdoor temperature in Celsius"); + addGauge("taupi_humidity_percent_innen", humidity_innen, "Indoor relative humidity in percent"); + addGauge("taupi_humidity_percent_aussen", humidity_aussen, "Outdoor relative humidity in percent"); + addGauge("taupi_dewpoint_celsius_innen", taupunkt_innen, "Indoor dew point in Celsius"); + addGauge("taupi_dewpoint_celsius_aussen", taupunkt_aussen, "Outdoor dew point in Celsius"); + addGauge("taupi_battery_percent_innen", battery_innen, "Indoor sensor battery percentage"); + addGauge("taupi_battery_percent_aussen", battery_aussen, "Outdoor sensor battery percentage"); + addGauge("taupi_lost_connection_seconds_innen", lost_connection_innen, "Seconds since last contact with indoor sensor"); + addGauge("taupi_lost_connection_seconds_aussen", lost_connection_aussen, "Seconds since last contact with outdoor sensor"); + addGauge("taupi_critical_humidity_threshold_percent", rh_activation, "Calculated relative humidity threshold above which it is critical"); + addGauge("taupi_is_critical", is_critical_val, "Flag indicating mold danger inside (humidity exceeds threshold)"); + addGauge("taupi_would_fan_activate", would_fan_activate, "Flag indicating if all fan activation criteria are met"); + + var relay_status = Shelly.getComponentStatus("switch", 0); + if (relay_status && typeof relay_status.output !== "undefined") { + addGauge("taupi_relay_status", relay_status.output, "Physical switch status (1 = on, 0 = off)"); + } + + res.body = body; + res.headers = [["Content-Type", "text/plain; version=0.0.4"]]; + res.code = 200; + res.send(); + }); +}