From d70e4c0be5bab8e0cf26108ffb424f5262f9fba7 Mon Sep 17 00:00:00 2001 From: max Date: Fri, 12 Jun 2026 13:59:16 +0200 Subject: [PATCH] feat: add FRITZ!Smart Energy 250 direction detection + Grafana dashboard The Smart Energy 250 grid meter reports two channels (-1 import, -2 export) with identical instantaneous power but separate energy counters. This adds a sliding-window algorithm (300s default) that compares cumulative energy deltas to reliably detect the current energy flow direction, even at low power levels where single-interval Wh deltas round to zero. New metrics: - ha_energy_direction (string: import/export/balanced/unknown) - ha_energy_direction_numeric (int: 1/-1/0) - ha_energy_net_power (float: signed Watt, negative = export) Includes a dedicated Grafana dashboard with net power time series, direction state timeline, and hourly import/export bar chart. --- README.md | 42 +++ .../classes/fritzbox/energy_direction.py | 107 +++++++ .../fritzbox/service_definitions/homeauto.py | 83 ++++++ .../fritzbox_smart_energy_250_dashboard.json | 271 ++++++++++++++++++ 4 files changed, 503 insertions(+) create mode 100644 fritzinfluxdb/classes/fritzbox/energy_direction.py create mode 100644 grafana/influx2_dashboards/fritzbox_smart_energy_250_dashboard.json diff --git a/README.md b/README.md index 6876bb6..ef0844f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Data collected: * connection related information (amount of data, throughput, daily up/download statistics) * all log messages * home automation (temperature, power consumption, heating settings) +* FRITZ!Smart Energy 250 grid meter (import/export direction detection, net power, per-channel energy) * phone call list (incoming, outgoing, missed, blocked) * system stats (cpu usage/temp, memory usage) * WLAN information (num clients, status, channels) @@ -233,9 +234,50 @@ InfluxDB >=2.2.0: * [fritzbox_logs_dashboard.json](https://github.com/bb-Ricardo/fritzinfluxdb/blob/main/grafana/influx2_dashboards/fritzbox_logs_dashboard.json) * [fritzbox_call_log_dashboard.json](https://github.com/bb-Ricardo/fritzinfluxdb/blob/main/grafana/influx2_dashboards/fritzbox_call_log_dashboard.json) * [fritzbox_home_automation_dashboard.json](https://github.com/bb-Ricardo/fritzinfluxdb/blob/main/grafana/influx2_dashboards/fritzbox_home_automation_dashboard.json) +* [fritzbox_smart_energy_250_dashboard.json](https://github.com/bb-Ricardo/fritzinfluxdb/blob/main/grafana/influx2_dashboards/fritzbox_smart_energy_250_dashboard.json) — grid import/export monitoring for PV systems *This was heavily inspired by: [https://grafana.com/grafana/dashboards/713-fritz-box-router-status/](https://grafana.com/grafana/dashboards/713-fritz-box-router-status/)* +## FRITZ!Smart Energy 250 + +The FRITZ!Smart Energy 250 is a smart meter reader that clips onto the utility meter's optical interface. +It reports two sub-devices via the AHA-HTTP-Interface: + +| AIN Suffix | Channel | Measurement | +|-----------|---------|-------------| +| `-1` | Grid import (Bezug, A+) | Energy consumed from the grid | +| `-2` | Grid export (Einspeisung, A-) | Energy fed into the grid (solar) | + +Both channels share the same instantaneous power reading. The energy counters (Wh) differ by direction. + +### Direction detection + +Since the power value alone cannot distinguish import from export, fritzinfluxdb uses a **sliding window comparison** (default: 300 seconds) over the cumulative energy counters of both channels. The channel whose counter grows faster determines the current direction. + +At low power levels (< 120 W), a single 30-second interval may not produce a 1 Wh delta — the sliding window accumulates enough energy to detect direction reliably. + +### Additional metrics + +These metrics are automatically collected when a Smart Energy 250 is detected: + +| Metric | Type | Description | +|--------|------|-------------| +| `ha_energy_direction` | string | Current flow direction: `import`, `export`, `balanced`, or `unknown` | +| `ha_energy_direction_numeric` | int | Numeric direction: `1` (import), `-1` (export), `0` (balanced/unknown) | +| `ha_energy_net_power` | float | Signed power in Watt — positive for import, negative for export | + +These complement the existing `ha_powermeter_power` and `ha_powermeter_energy` metrics which are collected per channel. + +### Grafana Dashboard + +A dedicated dashboard is included: `grafana/influx2_dashboards/fritzbox_smart_energy_250_dashboard.json` + +Panels: +* Current power, direction indicator, net power stat +* Net power time series (green below zero = export, red above = import) +* Direction timeline (state-timeline panel) +* Hourly import vs. export energy (stacked bar chart) + ## Configure more attributes check here to find an overview of more attributes which probably could be added diff --git a/fritzinfluxdb/classes/fritzbox/energy_direction.py b/fritzinfluxdb/classes/fritzbox/energy_direction.py new file mode 100644 index 0000000..2416799 --- /dev/null +++ b/fritzinfluxdb/classes/fritzbox/energy_direction.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022 - 2023 Ricardo Bartels. All rights reserved. +# +# fritzinfluxdb.py +# +# This work is licensed under the terms of the MIT license. +# For a copy, see file LICENSE.txt included in this +# repository or visit: . + +""" + Energy direction detection for FRITZ!Smart Energy 250. + + The Smart Energy 250 reports two sub-devices via AHA-HTTP-Interface: + - AIN suffix "-1": grid import (Bezug, A+) + - AIN suffix "-2": grid export (Einspeisung, A-) + + Both channels report the same instantaneous power value — only the + cumulative energy counters (Wh) differ by direction. Since the counters + have 1 Wh resolution, short polling intervals (< 60s) at low power + levels produce zero deltas. A sliding window (default 300s) accumulates + enough energy to reliably detect direction. +""" + +import time +from collections import deque + +from fritzinfluxdb.log import get_logger + +log = get_logger() + + +class EnergyDirectionTracker: + """ + Tracks energy counter readings per device and derives the current + energy flow direction from a sliding window comparison. + """ + + def __init__(self, window_seconds=300): + self.window_seconds = window_seconds + self._history = {} + + def update(self, ain, energy_wh): + now = time.monotonic() + if ain not in self._history: + self._history[ain] = deque() + self._history[ain].append((now, energy_wh)) + + cutoff = now - self.window_seconds - 30 + while self._history[ain] and self._history[ain][0][0] < cutoff: + self._history[ain].popleft() + + def get_delta(self, ain): + entries = self._history.get(ain) + if not entries or len(entries) < 2: + return None + + now = time.monotonic() + target_time = now - self.window_seconds + oldest = None + for ts, val in entries: + if ts <= target_time: + oldest = (ts, val) + else: + break + + if oldest is None: + oldest = entries[0] + + newest = entries[-1] + if oldest[0] == newest[0]: + return None + + return max(0, newest[1] - oldest[1]) + + +_tracker = EnergyDirectionTracker() + + +def track_energy_reading(ain, energy_wh): + _tracker.update(ain, energy_wh) + + +def get_energy_direction(bezug_ain, einsp_ain): + b_delta = _tracker.get_delta(bezug_ain) + e_delta = _tracker.get_delta(einsp_ain) + + if b_delta is None or e_delta is None: + return "unknown" + + if e_delta > b_delta: + return "export" + elif b_delta > e_delta: + return "import" + else: + return "balanced" + + +def get_energy_direction_numeric(bezug_ain, einsp_ain): + direction = get_energy_direction(bezug_ain, einsp_ain) + return {"export": -1, "import": 1, "balanced": 0, "unknown": 0}.get(direction, 0) + + +def get_net_power(power_w, bezug_ain, einsp_ain): + direction = get_energy_direction(bezug_ain, einsp_ain) + if direction == "export": + return -abs(power_w) + return abs(power_w) diff --git a/fritzinfluxdb/classes/fritzbox/service_definitions/homeauto.py b/fritzinfluxdb/classes/fritzbox/service_definitions/homeauto.py index 0e195b6..f261fbd 100644 --- a/fritzinfluxdb/classes/fritzbox/service_definitions/homeauto.py +++ b/fritzinfluxdb/classes/fritzbox/service_definitions/homeauto.py @@ -19,6 +19,9 @@ from fritzinfluxdb.common import grab, in_test_mode from fritzinfluxdb.classes.fritzbox.service_handler import FritzBoxLuaURLPath from fritzinfluxdb.classes.fritzbox.service_definitions import lua_services +from fritzinfluxdb.classes.fritzbox.energy_direction import ( + track_energy_reading, get_energy_direction, get_energy_direction_numeric, get_net_power +) home_automation_device_classes = { 0: "HAN-FUN", @@ -134,9 +137,45 @@ def get_ha_powermeter_energy(data): if in_test_mode(): return float(energy) + float(datetime.now().timestamp() - test_start_ts) + ain = data.get("@identifier", "").strip() + if ain and energy is not None: + track_energy_reading(ain, float(energy)) + return energy +def _get_channel_pair(data): + ain = data.get("@identifier", "").strip() + if not ain.endswith("-1"): + return None, None + base = ain[:-2] + return ain, base + "-2" + + +def get_ha_energy_direction(data): + bezug_ain, einsp_ain = _get_channel_pair(data) + if not bezug_ain: + return None + return get_energy_direction(bezug_ain, einsp_ain) + + +def get_ha_energy_direction_numeric(data): + bezug_ain, einsp_ain = _get_channel_pair(data) + if not bezug_ain: + return None + return get_energy_direction_numeric(bezug_ain, einsp_ain) + + +def get_ha_net_power(data): + bezug_ain, einsp_ain = _get_channel_pair(data) + if not bezug_ain: + return None + power = get_ha_powermeter_power(data) + if power is None: + return None + return get_net_power(power, bezug_ain, einsp_ain) + + def get_ha_powermeter_voltage(data): if in_test_mode(): @@ -569,6 +608,50 @@ def prepare_response_data(response): "exclude_filter_function": lambda data: "device" not in data.get("devicelist").keys() }, + # Smart Energy 250 — energy direction detection + "ha_energy_direction": { + "data_path": "devicelist.device", + "type": list, + "next": { + "type": str, + "tags_function": lambda data: {"name": data.get("name")}, + "value_function": get_ha_energy_direction, + "exclude_filter_function": lambda data: ( + "Smart Energy" not in data.get("@productname", "") + or not data.get("@identifier", "").strip().endswith("-1") + ) + }, + "exclude_filter_function": lambda data: "device" not in data.get("devicelist").keys() + }, + "ha_energy_direction_numeric": { + "data_path": "devicelist.device", + "type": list, + "next": { + "type": int, + "tags_function": lambda data: {"name": data.get("name")}, + "value_function": get_ha_energy_direction_numeric, + "exclude_filter_function": lambda data: ( + "Smart Energy" not in data.get("@productname", "") + or not data.get("@identifier", "").strip().endswith("-1") + ) + }, + "exclude_filter_function": lambda data: "device" not in data.get("devicelist").keys() + }, + "ha_energy_net_power": { + "data_path": "devicelist.device", + "type": list, + "next": { + "type": float, + "tags_function": lambda data: {"name": data.get("name")}, + "value_function": get_ha_net_power, + "exclude_filter_function": lambda data: ( + "Smart Energy" not in data.get("@productname", "") + or not data.get("@identifier", "").strip().endswith("-1") + ) + }, + "exclude_filter_function": lambda data: "device" not in data.get("devicelist").keys() + }, + # Alarm "ha_alert": { "data_path": "devicelist.device", diff --git a/grafana/influx2_dashboards/fritzbox_smart_energy_250_dashboard.json b/grafana/influx2_dashboards/fritzbox_smart_energy_250_dashboard.json new file mode 100644 index 0000000..e73250e --- /dev/null +++ b/grafana/influx2_dashboards/fritzbox_smart_energy_250_dashboard.json @@ -0,0 +1,271 @@ +{ + "__inputs": [ + { + "name": "DS_INFLUXDB", + "label": "InfluxDB", + "description": "", + "type": "datasource", + "pluginId": "influxdb", + "pluginName": "InfluxDB" + }, + { + "name": "VAR_MEASUREMENT", + "type": "constant", + "label": "InfluxDB measurement", + "value": "fritzbox", + "description": "" + } + ], + "__elements": {}, + "__requires": [ + { + "type": "panel", + "id": "gauge", + "name": "Gauge", + "version": "" + }, + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "9.2.2" + }, + { + "type": "datasource", + "id": "influxdb", + "name": "InfluxDB", + "version": "1.0.0" + }, + { + "type": "panel", + "id": "stat", + "name": "Stat", + "version": "" + }, + { + "type": "panel", + "id": "state-timeline", + "name": "State timeline", + "version": "" + }, + { + "type": "panel", + "id": "timeseries", + "name": "Time series", + "version": "" + } + ], + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "title": "Current Power", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 }, + "fieldConfig": { + "defaults": { + "unit": "watt", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 500 }, + { "color": "red", "value": 2000 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" }, + "query": "from(bucket: v.defaultBucket)\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"${VAR_MEASUREMENT}\")\n |> filter(fn: (r) => r._field == \"ha_powermeter_power\")\n |> last()" + } + ] + }, + { + "title": "Energy Direction", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "import": { "text": "Grid Import", "color": "red" } } }, + { "type": "value", "options": { "export": { "text": "Solar Export", "color": "green" } } }, + { "type": "value", "options": { "balanced": { "text": "Balanced", "color": "blue" } } }, + { "type": "value", "options": { "unknown": { "text": "Detecting...", "color": "gray" } } } + ] + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" }, + "query": "from(bucket: v.defaultBucket)\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"${VAR_MEASUREMENT}\")\n |> filter(fn: (r) => r._field == \"ha_energy_direction\")\n |> last()" + } + ] + }, + { + "title": "Net Power (positive=import, negative=export)", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 }, + "fieldConfig": { + "defaults": { + "unit": "watt", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "blue", "value": 0 }, + { "color": "red", "value": 1 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" }, + "query": "from(bucket: v.defaultBucket)\n |> range(start: -5m)\n |> filter(fn: (r) => r._measurement == \"${VAR_MEASUREMENT}\")\n |> filter(fn: (r) => r._field == \"ha_energy_net_power\")\n |> last()" + } + ] + }, + { + "title": "Today: Import Energy", + "type": "stat", + "gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 }, + "fieldConfig": { + "defaults": { + "unit": "watth", + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 5000 }, + { "color": "red", "value": 10000 } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" }, + "query": "from(bucket: v.defaultBucket)\n |> range(start: today())\n |> filter(fn: (r) => r._measurement == \"${VAR_MEASUREMENT}\")\n |> filter(fn: (r) => r._field == \"ha_powermeter_energy\")\n |> filter(fn: (r) => r.name =~ /.*-1.*/)\n |> spread()" + } + ] + }, + { + "title": "Net Power over Time", + "description": "Positive = grid import (consuming), Negative = grid export (solar surplus)", + "type": "timeseries", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 4 }, + "fieldConfig": { + "defaults": { + "unit": "watt", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 20, + "gradientMode": "scheme", + "thresholdsStyle": { "mode": "line" } + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "transparent", "value": 0 }, + { "color": "red", "value": 0.01 } + ] + } + }, + "overrides": [] + }, + "options": { + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" }, + "query": "from(bucket: v.defaultBucket)\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"${VAR_MEASUREMENT}\")\n |> filter(fn: (r) => r._field == \"ha_energy_net_power\")\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)" + } + ] + }, + { + "title": "Energy Direction Timeline", + "type": "state-timeline", + "gridPos": { "h": 4, "w": 24, "x": 0, "y": 14 }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "-1": { "text": "Export", "color": "green" } } }, + { "type": "value", "options": { "0": { "text": "Balanced", "color": "blue" } } }, + { "type": "value", "options": { "1": { "text": "Import", "color": "red" } } } + ] + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" }, + "query": "from(bucket: v.defaultBucket)\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"${VAR_MEASUREMENT}\")\n |> filter(fn: (r) => r._field == \"ha_energy_direction_numeric\")\n |> aggregateWindow(every: v.windowPeriod, fn: last, createEmpty: false)" + } + ] + }, + { + "title": "Import vs Export Energy (per channel)", + "type": "timeseries", + "gridPos": { "h": 10, "w": 24, "x": 0, "y": 18 }, + "fieldConfig": { + "defaults": { + "unit": "watth", + "custom": { + "drawStyle": "bars", + "fillOpacity": 70, + "stacking": { "mode": "normal" } + } + }, + "overrides": [ + { + "matcher": { "id": "byRegexp", "options": ".*-1.*" }, + "properties": [ + { "id": "displayName", "value": "Grid Import (Bezug)" }, + { "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } } + ] + }, + { + "matcher": { "id": "byRegexp", "options": ".*-2.*" }, + "properties": [ + { "id": "displayName", "value": "Grid Export (Einspeisung)" }, + { "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } } + ] + } + ] + }, + "targets": [ + { + "datasource": { "type": "influxdb", "uid": "${DS_INFLUXDB}" }, + "query": "from(bucket: v.defaultBucket)\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"${VAR_MEASUREMENT}\")\n |> filter(fn: (r) => r._field == \"ha_powermeter_energy\")\n |> filter(fn: (r) => r.name =~ /.*-[12].*/)\n |> aggregateWindow(every: 1h, fn: spread, createEmpty: false)" + } + ] + } + ], + "schemaVersion": 37, + "style": "dark", + "tags": ["fritzbox", "smart-energy-250", "solar"], + "templating": { "list": [] }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "", + "title": "FritzBox Smart Energy 250", + "uid": "", + "version": 0, + "weekStart": "" +}