diff --git a/README.md b/README.md index f5c18da..bab5539 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,12 @@ Supported environment variables: - `MQTT_SCRUBBER_APP_FLUSH_TIMEOUT` - `MQTT_SCRUBBER_APP_LOG_LEVEL` - `MQTT_SCRUBBER_APP_HEALTH_ADDRESS` +- `MQTT_SCRUBBER_APP_TASMOTA_TIME_ZONE` `MQTT_SCRUBBER_MQTT_TOPICS` expects a comma-separated list. +Timezone-less Tasmota `Time` values are interpreted in `tasmota_time_zone`. Set it to your device timezone such as `Europe/Prague` so the stored timestamp matches the device-local clock. The default is `UTC` for deterministic behavior. + You can also define optional per-device aliases in config with a top-level `device_aliases` object. Keys are normalized like device tags, so `kitchen-plug`, `Kitchen Plug`, and `kitchen_plug` all resolve to `kitchen_plug`. ## Run diff --git a/config.example.json b/config.example.json index f76280f..6f5dc64 100644 --- a/config.example.json +++ b/config.example.json @@ -26,6 +26,7 @@ "flush_interval": "10s", "flush_timeout": "10s", "log_level": "info", - "health_address": ":8080" + "health_address": ":8080", + "tasmota_time_zone": "Europe/Prague" } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ea864af..caf25c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - ./config.json:/app/config.json:ro environment: MQTT_SCRUBBER_APP_HEALTH_ADDRESS: ":8080" + MQTT_SCRUBBER_APP_TASMOTA_TIME_ZONE: "Europe/Prague" networks: - iot-network healthcheck: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bc75756..6ff22c4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -140,7 +140,7 @@ timestamp: payload Time or message receive time - prefer payload `Time` when present - accept RFC3339 and timezone-less Tasmota timestamps in the form `2006-01-02T15:04:05` -- current implementation interprets timezone-less timestamps as UTC +- interpret timezone-less timestamps in the configured `tasmota_time_zone` location and convert them to UTC for storage ## Failure handling diff --git a/docs/grafana/tasmota-device-detail-dashboard.json b/docs/grafana/tasmota-device-detail-dashboard.json index 24b8e80..dc4364b 100644 --- a/docs/grafana/tasmota-device-detail-dashboard.json +++ b/docs/grafana/tasmota-device-detail-dashboard.json @@ -41,848 +41,9 @@ "x": 0, "y": 0 }, - "id": 102, + "id": 101, "panels": [], - "title": "Selected Device Detail", - "type": "row" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "dateTimeAsIso" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 0, - "y": 1 - }, - "id": 7, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "value" - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "table", - "query": "SELECT to_unixtime(max(time)) * 1000 AS last_seen FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", - "rawQuery": true, - "rawSql": "SELECT to_unixtime(max(time)) * 1000 AS last_seen FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", - "refId": "A" - } - ], - "title": "Selected Last Seen", - "type": "stat" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "orange", - "value": 300 - }, - { - "color": "red", - "value": 1800 - } - ] - }, - "unit": "s" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 6, - "y": 1 - }, - "id": 8, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "value" - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "table", - "query": "SELECT to_unixtime(now()) - to_unixtime(max(time)) AS last_seen_age_s FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", - "rawQuery": true, - "rawSql": "SELECT to_unixtime(now()) - to_unixtime(max(time)) AS last_seen_age_s FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", - "refId": "A" - } - ], - "title": "Seconds Since Last Message", - "type": "stat" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "watt" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 12, - "y": 1 - }, - "id": 9, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "value" - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "table", - "query": "SELECT energy_power AS current_draw_w FROM tasmota_sensor WHERE device = '${device}' AND energy_power IS NOT NULL ORDER BY time DESC LIMIT 1", - "rawQuery": true, - "rawSql": "SELECT energy_power AS current_draw_w FROM tasmota_sensor WHERE device = '${device}' AND energy_power IS NOT NULL ORDER BY time DESC LIMIT 1", - "refId": "A" - } - ], - "title": "Current Draw", - "type": "stat" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 4, - "w": 6, - "x": 18, - "y": 1 - }, - "id": 10, - "options": { - "colorMode": "background", - "graphMode": "none", - "justifyMode": "center", - "orientation": "horizontal", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "textMode": "value" - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "table", - "query": "SELECT energy_total AS total_draw_kwh FROM tasmota_sensor WHERE device = '${device}' AND energy_total IS NOT NULL ORDER BY time DESC LIMIT 1", - "rawQuery": true, - "rawSql": "SELECT energy_total AS total_draw_kwh FROM tasmota_sensor WHERE device = '${device}' AND energy_total IS NOT NULL ORDER BY time DESC LIMIT 1", - "refId": "A" - } - ], - "title": "Total Draw", - "type": "stat" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 10, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "linear", - "lineWidth": 2, - "pointSize": 4, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "auto", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "watt" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 5 - }, - "id": 11, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT $__dateBin(time) AS time, device, avg(energy_power) AS power_w FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_power IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "rawQuery": true, - "rawSql": "SELECT $__dateBin(time) AS time, device, avg(energy_power) AS power_w FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_power IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "refId": "A" - } - ], - "title": "Selected Device Power Draw", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "volt" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 5 - }, - "id": 12, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT $__dateBin(time) AS time, device, avg(energy_voltage) AS voltage_v FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_voltage IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "rawQuery": true, - "rawSql": "SELECT $__dateBin(time) AS time, device, avg(energy_voltage) AS voltage_v FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_voltage IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "refId": "A" - } - ], - "title": "Selected Device Voltage", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "celsius" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 0, - "y": 13 - }, - "id": 13, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT $__dateBin(time) AS time, device, avg(analog_temperature) AS temperature_c FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_temperature IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "rawQuery": true, - "rawSql": "SELECT $__dateBin(time) AS time, device, avg(analog_temperature) AS temperature_c FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_temperature IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "refId": "A" - } - ], - "title": "Analog Temperature", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "dBm" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 8, - "y": 13 - }, - "id": 14, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT $__dateBin(time) AS time, device, avg(wifi_signal) AS wifi_signal_dbm FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND wifi_signal IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "rawQuery": true, - "rawSql": "SELECT $__dateBin(time) AS time, device, avg(wifi_signal) AS wifi_signal_dbm FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND wifi_signal IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "refId": "A" - } - ], - "title": "WiFi Signal", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "hour" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 8, - "x": 16, - "y": 13 - }, - "id": 15, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT $__dateBin(time) AS time, device, max(uptime_sec) / 3600.0 AS uptime_hours FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND uptime_sec IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "rawQuery": true, - "rawSql": "SELECT $__dateBin(time) AS time, device, max(uptime_sec) / 3600.0 AS uptime_hours FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND uptime_sec IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "refId": "A" - } - ], - "title": "Uptime", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "kwatth" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 21 - }, - "id": 16, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT $__dateBin(time) AS time, device, max(energy_total) AS energy_total_kwh FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_total IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "rawQuery": true, - "rawSql": "SELECT $__dateBin(time) AS time, device, max(energy_total) AS energy_total_kwh FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_total IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "refId": "A" - } - ], - "title": "Energy Total", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "palette-classic" - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - }, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 21 - }, - "id": 17, - "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true - }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT $__dateBin(time) AS time, device, avg(analog_a0) AS analog_a0_value FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_a0 IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "rawQuery": true, - "rawSql": "SELECT $__dateBin(time) AS time, device, avg(analog_a0) AS analog_a0_value FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_a0 IS NOT NULL GROUP BY 1, device ORDER BY 1, device", - "refId": "A" - } - ], - "title": "Analog A0", - "type": "timeseries" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "fieldConfig": { - "defaults": { - "custom": { - "align": "auto", - "cellOptions": { - "type": "auto" - }, - "inspect": false - }, - "mappings": [], - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": null - } - ] - } - }, - "overrides": [ - { - "matcher": { - "id": "byName", - "options": "time" - }, - "properties": [ - { - "id": "unit", - "value": "dateTimeAsIso" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "wifi_signal" - }, - "properties": [ - { - "id": "unit", - "value": "dBm" - } - ] - }, - { - "matcher": { - "id": "byName", - "options": "uptime_sec" - }, - "properties": [ - { - "id": "unit", - "value": "s" - } - ] - } - ] - }, - "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 29 - }, - "id": 18, - "options": { - "cellHeight": "sm", - "footer": { - "countRows": false, - "fields": "", - "reducer": [ - "sum" - ], - "show": false - }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "time" - } - ] - }, - "pluginVersion": "11.1.0", - "targets": [ - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "table", - "query": "SELECT time, device, power, power1, power2, power3, power4, wifi_signal, uptime_sec FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' ORDER BY time DESC LIMIT 50", - "rawQuery": true, - "rawSql": "SELECT time, device, power, power1, power2, power3, power4, wifi_signal, uptime_sec FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' ORDER BY time DESC LIMIT 50", - "refId": "A" - } - ], - "title": "Recent State Snapshots", - "type": "table" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 37 - }, - "id": 103, - "panels": [], - "title": "Selected Device Relay Status", + "title": "Current Relay Status", "type": "row" }, { @@ -939,7 +100,7 @@ "h": 4, "w": 4, "x": 0, - "y": 38 + "y": 1 }, "id": 19, "options": { @@ -1028,7 +189,7 @@ "h": 4, "w": 4, "x": 4, - "y": 38 + "y": 1 }, "id": 20, "options": { @@ -1117,7 +278,7 @@ "h": 4, "w": 4, "x": 8, - "y": 38 + "y": 1 }, "id": 21, "options": { @@ -1206,7 +367,7 @@ "h": 4, "w": 4, "x": 12, - "y": 38 + "y": 1 }, "id": 22, "options": { @@ -1295,7 +456,7 @@ "h": 4, "w": 4, "x": 16, - "y": 38 + "y": 1 }, "id": 23, "options": { @@ -1336,11 +497,11 @@ "h": 1, "w": 24, "x": 0, - "y": 42 + "y": 5 }, - "id": 104, + "id": 102, "panels": [], - "title": "Relay History", + "title": "Latest Sensor And State Data", "type": "row" }, { @@ -1351,92 +512,42 @@ "fieldConfig": { "defaults": { "color": { - "mode": "palette-classic" + "mode": "thresholds" }, - "custom": { - "axisBorderShow": false, - "axisCenteredZero": false, - "axisColorMode": "text", - "axisLabel": "", - "axisPlacement": "auto", - "barAlignment": 0, - "drawStyle": "line", - "fillOpacity": 12, - "gradientMode": "none", - "hideFrom": { - "legend": false, - "tooltip": false, - "viz": false - }, - "insertNulls": false, - "lineInterpolation": "stepAfter", - "lineWidth": 2, - "pointSize": 3, - "scaleDistribution": { - "type": "linear" - }, - "showPoints": "never", - "spanNulls": false, - "stacking": { - "group": "A", - "mode": "none" - }, - "thresholdsStyle": { - "mode": "off" - } - }, - "mappings": [ - { - "options": { - "0": { - "color": "red", - "text": "OFF" - }, - "1": { - "color": "green", - "text": "ON" - } - }, - "type": "value" - } - ], - "max": 1, - "min": 0, + "mappings": [], "thresholds": { "mode": "absolute", "steps": [ - { - "color": "red", - "value": null - }, { "color": "green", - "value": 1 + "value": null } ] }, - "unit": "short" + "unit": "dateTimeAsIso" }, "overrides": [] }, "gridPos": { - "h": 8, - "w": 24, + "h": 4, + "w": 6, "x": 0, - "y": 43 + "y": 6 }, - "id": 25, + "id": 7, "options": { - "legend": { - "calcs": [], - "displayMode": "list", - "placement": "bottom", - "showLegend": true + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false }, - "tooltip": { - "mode": "multi", - "sort": "desc" - } + "textMode": "value" }, "pluginVersion": "11.1.0", "targets": [ @@ -1446,63 +557,316 @@ "uid": "dfftuvrrhv6kgb" }, "editorMode": "code", - "format": "time_series", - "query": "SELECT time, CASE WHEN upper(power) = 'ON' THEN 1 WHEN upper(power) = 'OFF' THEN 0 ELSE NULL END AS relay_power_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power IS NOT NULL ORDER BY time", + "format": "table", + "query": "SELECT to_unixtime(max(time)) * 1000 AS last_seen FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", "rawQuery": true, - "rawSql": "SELECT time, CASE WHEN upper(power) = 'ON' THEN 1 WHEN upper(power) = 'OFF' THEN 0 ELSE NULL END AS relay_power_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power IS NOT NULL ORDER BY time", + "rawSql": "SELECT to_unixtime(max(time)) * 1000 AS last_seen FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", "refId": "A" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT time, CASE WHEN upper(power1) = 'ON' THEN 1 WHEN upper(power1) = 'OFF' THEN 0 ELSE NULL END AS relay_1_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power1 IS NOT NULL ORDER BY time", - "rawQuery": true, - "rawSql": "SELECT time, CASE WHEN upper(power1) = 'ON' THEN 1 WHEN upper(power1) = 'OFF' THEN 0 ELSE NULL END AS relay_1_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power1 IS NOT NULL ORDER BY time", - "refId": "B" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT time, CASE WHEN upper(power2) = 'ON' THEN 1 WHEN upper(power2) = 'OFF' THEN 0 ELSE NULL END AS relay_2_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power2 IS NOT NULL ORDER BY time", - "rawQuery": true, - "rawSql": "SELECT time, CASE WHEN upper(power2) = 'ON' THEN 1 WHEN upper(power2) = 'OFF' THEN 0 ELSE NULL END AS relay_2_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power2 IS NOT NULL ORDER BY time", - "refId": "C" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT time, CASE WHEN upper(power3) = 'ON' THEN 1 WHEN upper(power3) = 'OFF' THEN 0 ELSE NULL END AS relay_3_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power3 IS NOT NULL ORDER BY time", - "rawQuery": true, - "rawSql": "SELECT time, CASE WHEN upper(power3) = 'ON' THEN 1 WHEN upper(power3) = 'OFF' THEN 0 ELSE NULL END AS relay_3_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power3 IS NOT NULL ORDER BY time", - "refId": "D" - }, - { - "datasource": { - "type": "influxdb", - "uid": "dfftuvrrhv6kgb" - }, - "editorMode": "code", - "format": "time_series", - "query": "SELECT time, CASE WHEN upper(power4) = 'ON' THEN 1 WHEN upper(power4) = 'OFF' THEN 0 ELSE NULL END AS relay_4_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power4 IS NOT NULL ORDER BY time", - "rawQuery": true, - "rawSql": "SELECT time, CASE WHEN upper(power4) = 'ON' THEN 1 WHEN upper(power4) = 'OFF' THEN 0 ELSE NULL END AS relay_4_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power4 IS NOT NULL ORDER BY time", - "refId": "E" } ], - "title": "Selected Device Relay History", - "type": "timeseries" + "title": "Last Seen", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 300 + }, + { + "color": "red", + "value": 1800 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 6 + }, + "id": 8, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value" + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "table", + "query": "SELECT to_unixtime(now()) - to_unixtime(max(time)) AS last_seen_age_s FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", + "rawQuery": true, + "rawSql": "SELECT to_unixtime(now()) - to_unixtime(max(time)) AS last_seen_age_s FROM (SELECT time FROM tasmota_state WHERE device = '${device}' UNION ALL SELECT time FROM tasmota_sensor WHERE device = '${device}')", + "refId": "A" + } + ], + "title": "Seconds Since Last Message", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "watt" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 6 + }, + "id": 9, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value" + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "table", + "query": "SELECT energy_power AS current_draw_w FROM tasmota_sensor WHERE device = '${device}' AND energy_power IS NOT NULL ORDER BY time DESC LIMIT 1", + "rawQuery": true, + "rawSql": "SELECT energy_power AS current_draw_w FROM tasmota_sensor WHERE device = '${device}' AND energy_power IS NOT NULL ORDER BY time DESC LIMIT 1", + "refId": "A" + } + ], + "title": "Current Draw", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "kwatth" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 6 + }, + "id": 10, + "options": { + "colorMode": "background", + "graphMode": "none", + "justifyMode": "center", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value" + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "table", + "query": "SELECT energy_total AS total_draw_kwh FROM tasmota_sensor WHERE device = '${device}' AND energy_total IS NOT NULL ORDER BY time DESC LIMIT 1", + "rawQuery": true, + "rawSql": "SELECT energy_total AS total_draw_kwh FROM tasmota_sensor WHERE device = '${device}' AND energy_total IS NOT NULL ORDER BY time DESC LIMIT 1", + "refId": "A" + } + ], + "title": "Total Draw", + "type": "stat" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "continuous-BlYlRd" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "time" + }, + "properties": [ + { + "id": "unit", + "value": "dateTimeAsIso" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "wifi_signal" + }, + "properties": [ + { + "id": "unit", + "value": "dBm" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "uptime_sec" + }, + "properties": [ + { + "id": "unit", + "value": "s" + } + ] + } + ] + }, + "gridPos": { + "h": 12, + "w": 12, + "x": 0, + "y": 10 + }, + "id": 30, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "table", + "query": "SELECT * FROM tasmota_state WHERE device = '${device}' ORDER BY time DESC LIMIT 1", + "rawQuery": true, + "rawSql": "SELECT * FROM tasmota_state WHERE device = '${device}' ORDER BY time DESC LIMIT 1", + "refId": "A" + } + ], + "title": "Latest State Values", + "type": "table" }, { "datasource": { @@ -1605,12 +969,12 @@ ] }, "gridPos": { - "h": 8, - "w": 24, - "x": 0, - "y": 51 + "h": 12, + "w": 12, + "x": 12, + "y": 10 }, - "id": 24, + "id": 31, "options": { "cellHeight": "sm", "footer": { @@ -1621,13 +985,7 @@ ], "show": false }, - "showHeader": true, - "sortBy": [ - { - "desc": true, - "displayName": "time" - } - ] + "showHeader": true }, "pluginVersion": "11.1.0", "targets": [ @@ -1638,14 +996,708 @@ }, "editorMode": "code", "format": "table", - "query": "SELECT time, device, energy_power, energy_voltage, energy_today, energy_total, analog_temperature, analog_a0, temp_unit FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' ORDER BY time DESC LIMIT 50", + "query": "SELECT * FROM tasmota_sensor WHERE device = '${device}' ORDER BY time DESC LIMIT 1", "rawQuery": true, - "rawSql": "SELECT time, device, energy_power, energy_voltage, energy_today, energy_total, analog_temperature, analog_a0, temp_unit FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' ORDER BY time DESC LIMIT 50", + "rawSql": "SELECT * FROM tasmota_sensor WHERE device = '${device}' ORDER BY time DESC LIMIT 1", "refId": "A" } ], - "title": "Recent Sensor Snapshots", + "title": "Latest Sensor Values", "type": "table" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 22 + }, + "id": 103, + "panels": [], + "title": "Historical Sensor And State Graphs", + "type": "row" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 4, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "watt" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 23 + }, + "id": 40, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, energy_power AS power_w FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_power IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, energy_power AS power_w FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_power IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "Power Draw", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "volt" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 23 + }, + "id": 41, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, energy_voltage AS voltage_v FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_voltage IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, energy_voltage AS voltage_v FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_voltage IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "Voltage", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "kwatth" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 31 + }, + "id": 42, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, energy_today AS energy_today_kwh FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_today IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, energy_today AS energy_today_kwh FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_today IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "Energy Today", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "kwatth" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 31 + }, + "id": 43, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, energy_total AS energy_total_kwh FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_total IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, energy_total AS energy_total_kwh FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND energy_total IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "Energy Total", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "celsius" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 39 + }, + "id": 44, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, analog_temperature AS temperature_c FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_temperature IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, analog_temperature AS temperature_c FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_temperature IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "Analog Temperature", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 39 + }, + "id": 45, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, analog_a0 AS analog_a0_value FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_a0 IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, analog_a0 AS analog_a0_value FROM tasmota_sensor WHERE $__timeFilter(time) AND device = '${device}' AND analog_a0 IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "Analog A0", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "dBm" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 39 + }, + "id": 46, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, wifi_signal AS wifi_signal_dbm FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND wifi_signal IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, wifi_signal AS wifi_signal_dbm FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND wifi_signal IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "WiFi Signal", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "hour" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 47 + }, + "id": 47, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, device, uptime_sec / 3600.0 AS uptime_hours FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND uptime_sec IS NOT NULL ORDER BY time, device", + "rawQuery": true, + "rawSql": "SELECT time, device, uptime_sec / 3600.0 AS uptime_hours FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND uptime_sec IS NOT NULL ORDER BY time, device", + "refId": "A" + } + ], + "title": "Uptime", + "type": "timeseries" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 12, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "stepAfter", + "lineWidth": 2, + "pointSize": 3, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [ + { + "options": { + "0": { + "color": "red", + "text": "OFF" + }, + "1": { + "color": "green", + "text": "ON" + } + }, + "type": "value" + } + ], + "max": 1, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 47 + }, + "id": 25, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "11.1.0", + "targets": [ + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, CASE WHEN upper(power) = 'ON' THEN 1 WHEN upper(power) = 'OFF' THEN 0 ELSE NULL END AS relay_power_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power IS NOT NULL ORDER BY time", + "rawQuery": true, + "rawSql": "SELECT time, CASE WHEN upper(power) = 'ON' THEN 1 WHEN upper(power) = 'OFF' THEN 0 ELSE NULL END AS relay_power_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power IS NOT NULL ORDER BY time", + "refId": "A" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, CASE WHEN upper(power1) = 'ON' THEN 1 WHEN upper(power1) = 'OFF' THEN 0 ELSE NULL END AS relay_1_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power1 IS NOT NULL ORDER BY time", + "rawQuery": true, + "rawSql": "SELECT time, CASE WHEN upper(power1) = 'ON' THEN 1 WHEN upper(power1) = 'OFF' THEN 0 ELSE NULL END AS relay_1_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power1 IS NOT NULL ORDER BY time", + "refId": "B" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, CASE WHEN upper(power2) = 'ON' THEN 1 WHEN upper(power2) = 'OFF' THEN 0 ELSE NULL END AS relay_2_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power2 IS NOT NULL ORDER BY time", + "rawQuery": true, + "rawSql": "SELECT time, CASE WHEN upper(power2) = 'ON' THEN 1 WHEN upper(power2) = 'OFF' THEN 0 ELSE NULL END AS relay_2_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power2 IS NOT NULL ORDER BY time", + "refId": "C" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, CASE WHEN upper(power3) = 'ON' THEN 1 WHEN upper(power3) = 'OFF' THEN 0 ELSE NULL END AS relay_3_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power3 IS NOT NULL ORDER BY time", + "rawQuery": true, + "rawSql": "SELECT time, CASE WHEN upper(power3) = 'ON' THEN 1 WHEN upper(power3) = 'OFF' THEN 0 ELSE NULL END AS relay_3_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power3 IS NOT NULL ORDER BY time", + "refId": "D" + }, + { + "datasource": { + "type": "influxdb", + "uid": "dfftuvrrhv6kgb" + }, + "editorMode": "code", + "format": "time_series", + "query": "SELECT time, CASE WHEN upper(power4) = 'ON' THEN 1 WHEN upper(power4) = 'OFF' THEN 0 ELSE NULL END AS relay_4_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power4 IS NOT NULL ORDER BY time", + "rawQuery": true, + "rawSql": "SELECT time, CASE WHEN upper(power4) = 'ON' THEN 1 WHEN upper(power4) = 'OFF' THEN 0 ELSE NULL END AS relay_4_state FROM tasmota_state WHERE $__timeFilter(time) AND device = '${device}' AND power4 IS NOT NULL ORDER BY time", + "refId": "E" + } + ], + "title": "Relay History", + "type": "timeseries" } ], "refresh": "30s", @@ -1695,6 +1747,6 @@ "timezone": "browser", "title": "Tasmota Device Detail", "uid": "tasmota-device-detail", - "version": 1, + "version": 4, "weekStart": "" } \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index da1f741..e174c14 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,12 +39,13 @@ type InfluxConfig struct { } type AppConfig struct { - BatchSize int `json:"batch_size"` - BufferSize int `json:"buffer_size"` - FlushInterval DurationValue `json:"flush_interval"` - FlushTimeout DurationValue `json:"flush_timeout"` - LogLevel string `json:"log_level"` - HealthAddress string `json:"health_address"` + BatchSize int `json:"batch_size"` + BufferSize int `json:"buffer_size"` + FlushInterval DurationValue `json:"flush_interval"` + FlushTimeout DurationValue `json:"flush_timeout"` + LogLevel string `json:"log_level"` + HealthAddress string `json:"health_address"` + TasmotaTimeZone string `json:"tasmota_time_zone"` } type DurationValue struct { @@ -124,6 +125,9 @@ func (cfg Config) Validate() error { if cfg.App.FlushTimeout.Duration <= 0 { return errors.New("app flush_timeout must be greater than zero") } + if _, err := loadConfiguredLocation(cfg.App.TasmotaTimeZone); err != nil { + return err + } return nil } @@ -145,12 +149,13 @@ func defaultConfig() Config { Precision: "ns", }, App: AppConfig{ - BatchSize: 200, - BufferSize: 1000, - FlushInterval: DurationValue{Duration: 10 * time.Second}, - FlushTimeout: DurationValue{Duration: 10 * time.Second}, - LogLevel: "info", - HealthAddress: ":8080", + BatchSize: 200, + BufferSize: 1000, + FlushInterval: DurationValue{Duration: 10 * time.Second}, + FlushTimeout: DurationValue{Duration: 10 * time.Second}, + LogLevel: "info", + HealthAddress: ":8080", + TasmotaTimeZone: "UTC", }, } } @@ -166,6 +171,7 @@ func applyEnvOverrides(cfg *Config) error { setString(&cfg.Influx.Precision, envPrefix+"INFLUX_PRECISION") setString(&cfg.App.LogLevel, envPrefix+"APP_LOG_LEVEL") setString(&cfg.App.HealthAddress, envPrefix+"APP_HEALTH_ADDRESS") + setString(&cfg.App.TasmotaTimeZone, envPrefix+"APP_TASMOTA_TIME_ZONE") if raw, ok := os.LookupEnv(envPrefix + "DEVICE_ALIASES"); ok { if strings.TrimSpace(raw) == "" { @@ -276,3 +282,29 @@ func normalizeDeviceKey(value string) string { normalized = strings.Trim(normalized, "_") return normalized } + +func (cfg AppConfig) TasmotaLocation() *time.Location { + location, err := loadConfiguredLocation(cfg.TasmotaTimeZone) + if err != nil { + return time.UTC + } + + return location +} + +func loadConfiguredLocation(name string) (*time.Location, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" || strings.EqualFold(trimmed, "utc") { + return time.UTC, nil + } + if strings.EqualFold(trimmed, "local") { + return time.Local, nil + } + + location, err := time.LoadLocation(trimmed) + if err != nil { + return nil, fmt.Errorf("app tasmota_time_zone %q is invalid: %w", trimmed, err) + } + + return location, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ff2a0f4..103b46f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,8 +7,6 @@ import ( ) func TestLoadNormalizesDeviceAliases(t *testing.T) { - t.Setenv("MQTT_SCRUBBER_DEVICE_ALIASES", "") - configPath := filepath.Join(t.TempDir(), "config.json") contents := `{ "mqtt": { @@ -105,3 +103,76 @@ func TestLoadOverridesDeviceAliasesFromEnv(t *testing.T) { t.Fatalf("unexpected desk alias: got %q", got) } } + +func TestLoadSupportsTasmotaTimeZone(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + contents := `{ + "mqtt": { + "broker": "tcp://127.0.0.1:1883", + "client_id": "mqqt-scrubber", + "topics": ["tele/+/STATE"], + "qos": 0 + }, + "influx": { + "url": "http://127.0.0.1:8181", + "database": "home", + "precision": "ns" + }, + "app": { + "batch_size": 200, + "buffer_size": 1000, + "flush_interval": "10s", + "flush_timeout": "10s", + "log_level": "info", + "health_address": ":8080", + "tasmota_time_zone": "Europe/Prague" + } + }` + + if err := os.WriteFile(configPath, []byte(contents), 0o644); err != nil { + t.Fatalf("write config file: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if got := cfg.App.TasmotaLocation().String(); got != "Europe/Prague" { + t.Fatalf("unexpected tasmota timezone: got %q", got) + } +} + +func TestLoadRejectsInvalidTasmotaTimeZone(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + contents := `{ + "mqtt": { + "broker": "tcp://127.0.0.1:1883", + "client_id": "mqqt-scrubber", + "topics": ["tele/+/STATE"], + "qos": 0 + }, + "influx": { + "url": "http://127.0.0.1:8181", + "database": "home", + "precision": "ns" + }, + "app": { + "batch_size": 200, + "buffer_size": 1000, + "flush_interval": "10s", + "flush_timeout": "10s", + "log_level": "info", + "health_address": ":8080", + "tasmota_time_zone": "Not/AZone" + } + }` + + if err := os.WriteFile(configPath, []byte(contents), 0o644); err != nil { + t.Fatalf("write config file: %v", err) + } + + if _, err := Load(configPath); err == nil { + t.Fatal("expected Load to reject invalid tasmota_time_zone") + } +} diff --git a/internal/parser/tasmota.go b/internal/parser/tasmota.go index 666f937..0dd825c 100644 --- a/internal/parser/tasmota.go +++ b/internal/parser/tasmota.go @@ -22,6 +22,14 @@ var tasmotaTimeLayouts = []string{ } func ParseTasmota(message model.RawMessage) ([]model.Record, error) { + return ParseTasmotaInLocation(message, time.UTC) +} + +func ParseTasmotaInLocation(message model.RawMessage, timezoneLessTimeLocation *time.Location) ([]model.Record, error) { + if timezoneLessTimeLocation == nil { + timezoneLessTimeLocation = time.UTC + } + parts := strings.Split(message.Topic, "/") if len(parts) != 3 { return nil, fmt.Errorf("unsupported topic shape: %s", message.Topic) @@ -52,7 +60,7 @@ func ParseTasmota(message model.RawMessage) ([]model.Record, error) { return nil, fmt.Errorf("no usable fields in payload for topic %s", message.Topic) } - timestamp := parsePayloadTimestamp(payload, message.ReceivedAt) + timestamp := parsePayloadTimestamp(payload, message.ReceivedAt, timezoneLessTimeLocation) record := model.Record{ Measurement: measurement, @@ -77,11 +85,14 @@ func parseLWT(message model.RawMessage, measurement string, tags map[string]stri } } -func parsePayloadTimestamp(payload map[string]any, fallback time.Time) time.Time { +func parsePayloadTimestamp(payload map[string]any, fallback time.Time, timezoneLessTimeLocation *time.Location) time.Time { rawTime, ok := payload["Time"].(string) if !ok || strings.TrimSpace(rawTime) == "" { return fallback } + if timezoneLessTimeLocation == nil { + timezoneLessTimeLocation = time.UTC + } for _, layout := range tasmotaTimeLayouts { var ( @@ -90,13 +101,13 @@ func parsePayloadTimestamp(payload map[string]any, fallback time.Time) time.Time ) if layout == "2006-01-02T15:04:05" { - parsed, err = time.ParseInLocation(layout, rawTime, time.UTC) + parsed, err = time.ParseInLocation(layout, rawTime, timezoneLessTimeLocation) } else { parsed, err = time.Parse(layout, rawTime) } if err == nil { - return parsed + return parsed.UTC() } } diff --git a/internal/parser/tasmota_test.go b/internal/parser/tasmota_test.go index bc6b1e1..dea21bd 100644 --- a/internal/parser/tasmota_test.go +++ b/internal/parser/tasmota_test.go @@ -85,6 +85,29 @@ func TestParseTasmotaFixtures(t *testing.T) { } } +func TestParseTasmotaInLocationUsesConfiguredTimezoneForTimezoneLessTime(t *testing.T) { + location := time.FixedZone("CET", 3600) + receivedAt := time.Date(2026, time.March, 16, 11, 55, 30, 0, time.UTC) + + records, err := ParseTasmotaInLocation(model.RawMessage{ + Topic: "tele/tasmota_67850B/SENSOR", + Payload: []byte(`{"Time":"2026-03-16T11:55:29","ENERGY":{"Power":9}}`), + ReceivedAt: receivedAt, + }, location) + if err != nil { + t.Fatalf("ParseTasmotaInLocation returned error: %v", err) + } + + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + + want := time.Date(2026, time.March, 16, 10, 55, 29, 0, time.UTC) + if !records[0].Timestamp.Equal(want) { + t.Fatalf("unexpected timestamp: got %s want %s", records[0].Timestamp.Format(time.RFC3339), want.Format(time.RFC3339)) + } +} + func fieldEquals(got any, want any) bool { switch typedWant := want.(type) { case float64: diff --git a/internal/pipeline/service.go b/internal/pipeline/service.go index d9493db..d895ad0 100644 --- a/internal/pipeline/service.go +++ b/internal/pipeline/service.go @@ -21,18 +21,19 @@ type writer interface { } type Service struct { - config config.Config - deviceAliases map[string]string - aliasRecords []model.Record - snapshots map[string]*deviceSnapshot - dirtyDevices map[string]struct{} - influxClient writer - input chan model.RawMessage - received atomic.Uint64 - parsed atomic.Uint64 - written atomic.Uint64 - dropped atomic.Uint64 - failed atomic.Uint64 + config config.Config + tasmotaTimeLocation *time.Location + deviceAliases map[string]string + aliasRecords []model.Record + snapshots map[string]*deviceSnapshot + dirtyDevices map[string]struct{} + influxClient writer + input chan model.RawMessage + received atomic.Uint64 + parsed atomic.Uint64 + written atomic.Uint64 + dropped atomic.Uint64 + failed atomic.Uint64 } type Snapshot struct { @@ -55,13 +56,14 @@ type deviceSnapshot struct { func NewService(cfg config.Config, influxClient writer) *Service { normalizedAliases := normalizeDeviceAliases(cfg.DeviceAliases) return &Service{ - config: cfg, - deviceAliases: normalizedAliases, - aliasRecords: buildAliasRecords(normalizedAliases), - snapshots: make(map[string]*deviceSnapshot), - dirtyDevices: make(map[string]struct{}), - influxClient: influxClient, - input: make(chan model.RawMessage, cfg.App.BufferSize), + config: cfg, + tasmotaTimeLocation: cfg.App.TasmotaLocation(), + deviceAliases: normalizedAliases, + aliasRecords: buildAliasRecords(normalizedAliases), + snapshots: make(map[string]*deviceSnapshot), + dirtyDevices: make(map[string]struct{}), + influxClient: influxClient, + input: make(chan model.RawMessage, cfg.App.BufferSize), } } @@ -113,7 +115,7 @@ func (service *Service) Run(ctx context.Context) error { service.logCounters() return nil case message := <-input: - records, err := parser.ParseTasmota(message) + records, err := parser.ParseTasmotaInLocation(message, service.tasmotaTimeLocation) if err != nil { service.failed.Add(1) slog.Warn("failed to parse message", "topic", message.Topic, "error", err)